Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -105,15 +105,17 @@ def annotate(
|
|||||||
component["reuse_note"] = NOTE_REUSE_MISSING
|
component["reuse_note"] = NOTE_REUSE_MISSING
|
||||||
continue
|
continue
|
||||||
count = entry.get("reuse_count")
|
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:
|
for component in targets:
|
||||||
component["reuse_count"] = count
|
component["reuse_count"] = count
|
||||||
component["reuse_note"] = (
|
component["reuse_note"] = (
|
||||||
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}」"
|
f"{basis} {count}회 — 「{entry.get('matched_example')}」"
|
||||||
if count
|
if count
|
||||||
else NOTE_NOT_APPLICABLE
|
else NOTE_NOT_APPLICABLE
|
||||||
)
|
)
|
||||||
if count:
|
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:
|
else:
|
||||||
missing.append(type_id)
|
missing.append(type_id)
|
||||||
return notes, sorted(set(missing))
|
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 "")
|
type_id = str(structure.get("type_id") or "")
|
||||||
entry = mapping.for_structure(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]]] = []
|
found: list[tuple[str, set[str]]] = []
|
||||||
if entry.get("billing_component"):
|
if entry.get("billing_component"):
|
||||||
found.append(("구조물 줄", {str(entry["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 []:
|
for part in composite.get("parts") or []:
|
||||||
if isinstance(part, dict):
|
if isinstance(part, dict):
|
||||||
label = f"묶음 조각 「{part.get('name') or part.get('code')}」"
|
label = f"묶음 조각 「{part.get('name') or part.get('code')}」"
|
||||||
|
|||||||
@@ -271,13 +271,20 @@ class WorkItemMapping:
|
|||||||
return row
|
return row
|
||||||
return None
|
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 []:
|
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 row
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -382,6 +389,30 @@ def composite_quantities(
|
|||||||
entry["not_ready"] = True
|
entry["not_ready"] = True
|
||||||
entry["why"] = why
|
entry["why"] = why
|
||||||
missing.append({"code": spec.get("code"), "reason": 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":
|
if suffix == "rebar_complexity":
|
||||||
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
||||||
complexity, why = rebar_complexity(
|
complexity, why = rebar_complexity(
|
||||||
@@ -459,10 +490,12 @@ def rebar_complexity(
|
|||||||
continue
|
continue
|
||||||
if row.get("form") == form:
|
if row.get("form") == form:
|
||||||
if row.get("class"):
|
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 "원문 예시에 없음")
|
return None, str(row.get("why") or "원문 예시에 없음")
|
||||||
if fallback and fallback.get("class"):
|
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
|
from B08_Quantity.B08_Quantity_Wording import type_label
|
||||||
|
|
||||||
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
|
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
|
||||||
|
|||||||
@@ -433,7 +433,11 @@ def _structure_rows(
|
|||||||
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
|
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
|
||||||
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
|
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
|
||||||
billing = _component_billing(structure, entry)
|
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
|
kind = structure_kind(structure) if composite else None
|
||||||
parts: list[dict[str, Any]] | None = None
|
parts: list[dict[str, Any]] | None = None
|
||||||
parts_missing: list[dict[str, Any]] = []
|
parts_missing: list[dict[str, Any]] = []
|
||||||
@@ -600,7 +604,7 @@ def _placing_rows(
|
|||||||
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
|
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
|
||||||
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
|
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 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"
|
"unconfirmed"
|
||||||
):
|
):
|
||||||
continue # 기본값으로 선 구조물도 — 금액에 안 듦
|
continue # 기본값으로 선 구조물도 — 금액에 안 듦
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ def rubble_base_rows(
|
|||||||
total = 0.0
|
total = 0.0
|
||||||
bases: list[str] = []
|
bases: list[str] = []
|
||||||
for structure in unit_quantity_table.get("structures") or []:
|
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"
|
"unconfirmed"
|
||||||
):
|
):
|
||||||
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
|
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ class CostInput:
|
|||||||
|
|
||||||
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
|
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
|
||||||
owner_supplied_for_safety_krw: Decimal | None = 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
|
owner_supplied_includes_vat: bool = True
|
||||||
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
|
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
|
||||||
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
|
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
|
||||||
|
|||||||
@@ -55,7 +55,18 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
|||||||
"prefix": "합판거푸집",
|
"prefix": "합판거푸집",
|
||||||
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
|
"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회사용시 …」 칸.
|
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
|
||||||
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
|
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
|
||||||
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
|
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
|
||||||
@@ -117,6 +128,8 @@ def match_judged_table(
|
|||||||
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||||
elif judged["shape"] == "use_count":
|
elif judged["shape"] == "use_count":
|
||||||
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
||||||
|
elif judged["shape"] == "remark_labor":
|
||||||
|
staged = _remark_labor(code, table, judged, rows, catalog)
|
||||||
else:
|
else:
|
||||||
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||||
if isinstance(staged, str):
|
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
|
amount = base * ratio / Decimal(100) / basis
|
||||||
staged.append(_row(code, table, entry, amount, unit, index, f"{count}회"))
|
staged.append(_row(code, table, entry, amount, unit, index, f"{count}회"))
|
||||||
return staged
|
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")
|
variable = dataset.variable("rate_safety_pct")
|
||||||
brackets = variable["brackets"]
|
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
|
owner_supplied = data.owner_supplied_for_safety_krw
|
||||||
if owner_supplied is None:
|
if owner_supplied is None:
|
||||||
owner_supplied = data.owner_supplied_material_krw
|
owner_supplied = data.owner_supplied_material_krw
|
||||||
if data.owner_supplied_includes_vat:
|
if data.owner_supplied_includes_vat:
|
||||||
|
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
|
||||||
|
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
|
||||||
|
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
|
||||||
owner_supplied = owner_supplied / _VAT_DIVISOR
|
owner_supplied = owner_supplied / _VAT_DIVISOR
|
||||||
|
|
||||||
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
|
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 를 곱한 뒤 한 번만 버려
|
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
|
||||||
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
|
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
|
||||||
# A(배수 1)는 어느 차례로 해도 같은 값이다.
|
# A(배수 1)는 어느 차례로 해도 같은 값이다.
|
||||||
|
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
|
||||||
|
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
|
||||||
|
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
|
||||||
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
||||||
|
|
||||||
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
|
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
|
||||||
|
|||||||
@@ -67,9 +67,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type_id": "pipe_inlet_basin",
|
"type_id": "pipe_inlet_basin",
|
||||||
"reuse_count": 6,
|
"reuse_count": 4,
|
||||||
"matched_example": "보호공 기초",
|
"matched_example": "거푸집 합판4회",
|
||||||
"note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요"
|
"note": "그 공종 표가 직접 적은 「거푸집 | 합판4회」 가 정본 — 1-7-1 분류(「호안 및 보호공의 기초」 6회)보다 위(2026-09-14 브레인 ㉯ ②). 앞서 6회로 봤음.",
|
||||||
|
"basis": "품셈 12-15 집수정 표"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type_id": "ford_pavement",
|
"type_id": "ford_pavement",
|
||||||
|
|||||||
@@ -51,9 +51,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type_id": "pipe_inlet_basin",
|
"type_id": "pipe_inlet_basin",
|
||||||
"class": "간단",
|
"class": "보통",
|
||||||
"matched": "간단한 기초",
|
"matched": "철근가공조립(보통)",
|
||||||
"note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요"
|
"basis": "품셈 12-15 집수정 표",
|
||||||
|
"note": "그 공종 표가 직접 적은 「철근 | 철근가공조립(보통)」 이 정본 — 12-3 [주]① 「간단한 기초」 추정보다 위(2026-09-14 브레인 ㉯ ⑶ · 거푸집 4회와 같은 원칙). 앞서 간단으로 봤음."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"price_hint_krw_per_ton": {
|
"price_hint_krw_per_ton": {
|
||||||
|
|||||||
@@ -327,6 +327,53 @@
|
|||||||
"composite": {
|
"composite": {
|
||||||
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
|
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
|
||||||
"items": [
|
"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",
|
"type_id": "retaining_wall",
|
||||||
"parts": [
|
"parts": [
|
||||||
@@ -346,7 +393,8 @@
|
|||||||
"unit": "㎡",
|
"unit": "㎡",
|
||||||
"from_components": [
|
"from_components": [
|
||||||
"합판거푸집"
|
"합판거푸집"
|
||||||
]
|
],
|
||||||
|
"kind_suffix": "formwork_reuse"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "FP-12-38",
|
"code": "FP-12-38",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"dataset_id": "data_work_item_master_manifest",
|
"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",
|
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||||
"source": {
|
"source": {
|
||||||
"dataset_id": "pum_forest",
|
"dataset_id": "pum_forest",
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"file": "work_item_master_2026-01-01.json",
|
"file": "work_item_master_2026-01-01.json",
|
||||||
"sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287",
|
"sha256": "183a2933a2bf6f06fb99c0b86dc4073ec42c12a95784b3dd279f5eee50ee088f",
|
||||||
"size_bytes": 838421
|
"size_bytes": 838487
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "form_undetermined_2026-01-01.json",
|
"file": "form_undetermined_2026-01-01.json",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"dataset_id": "work_item_master_forest",
|
"dataset_id": "work_item_master_forest",
|
||||||
"effective_date": "2026-01-01",
|
"effective_date": "2026-01-01",
|
||||||
"pum_edition": "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_version": {
|
||||||
"dataset_id": "pum_forest",
|
"dataset_id": "pum_forest",
|
||||||
"effective_date": "2026-01-01",
|
"effective_date": "2026-01-01",
|
||||||
@@ -33148,7 +33148,9 @@
|
|||||||
"formula_rows": [],
|
"formula_rows": [],
|
||||||
"special_glyphs": [],
|
"special_glyphs": [],
|
||||||
"capacity_formula_here": false,
|
"capacity_formula_here": false,
|
||||||
"variant_key": [],
|
"variant_key": [
|
||||||
|
"버림콘크리트"
|
||||||
|
],
|
||||||
"condition_note": [
|
"condition_note": [
|
||||||
"구 분",
|
"구 분",
|
||||||
"규 격",
|
"규 격",
|
||||||
@@ -33209,7 +33211,9 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"variant_keys": []
|
"variant_keys": [
|
||||||
|
"버림콘크리트"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"work_item_code": "FP-12-16",
|
"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
|
assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000
|
||||||
# 관급 제외 밑수(B)는 관급이 들어도 안 움직임.
|
# 관급 제외 밑수(B)는 관급이 들어도 안 움직임.
|
||||||
assert owner.line("safety_management_cost_b").base_amount_krw == base
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user