- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변 - 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭 - 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기) - 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀 - 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
330 lines
14 KiB
Python
330 lines
14 KiB
Python
"""B09 계약 단계 — 당초설계 → 계약내역 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 브레인 배정).
|
|
|
|
근거: STmate 분석 `27_계약_실행_기성_단계.md` §2 (`wM_Mk_Cont`) ·
|
|
`35_형식과_단계의_공통과_차이.md` §3.
|
|
· 낙찰률 하나가 아니라 **노무·재료·경비별 단가 적용률**
|
|
· 옵션 여섯(아래 `OPTIONS` — 화면 표기 그대로)
|
|
· **행별 적용 제외**(「적용율을 제외할(ex 관급자재대..) 공정을 선택」)
|
|
· 0% 이면 계약단가(W코드)를 「0」化 — 빈 줄이 아니라 **공내역** 줄
|
|
|
|
⚠ **설계를 안 건드린다** — 설계 내역(`/estimation/bill`) 줄을 **복사해** 적용률을 얹는다.
|
|
설계 내역·원가계산서·골든셋은 그대로다.
|
|
⚠ **값이 맞다가 아니라 구조가 선다까지** — 계약 표본 0건(27번 §9)이라 원 단위로 못 맞춰 봄.
|
|
절사는 설계 내역과 같은 규칙(`bill_line` — 성분 단가 원 미만 · 줄 성분마다 절사)을 빌려 씀.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from dataclasses import replace
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
# ⚠ `_Rows` 를 먼저 부르면 순환 import — 설계 내역 본체가 다시 내보내는 자리에서 받음.
|
|
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
|
|
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceBookError, PriceKind
|
|
|
|
_ZERO = Decimal(0)
|
|
_HUNDRED = Decimal(100)
|
|
|
|
#: 저장 자리 — `estimation` 구획 안 한 칸.
|
|
SETTINGS_KEY = "contract"
|
|
|
|
#: 단가 적용률 — STmate 【 노 】【 재 】【 경 】 차례.
|
|
RATE_FIELDS: tuple[tuple[str, str], ...] = (
|
|
("labor_pct", "노"),
|
|
("material_pct", "재"),
|
|
("expense_pct", "경"),
|
|
)
|
|
|
|
#: 적용 옵션 — 화면 표기 그대로(27번 §2.2). 뜻이 확인 안 된 것은 칸만 받고 까닭을 적음.
|
|
OPTIONS: tuple[tuple[str, str], ...] = (
|
|
("apply_to_base_prices", "기초단가(재,노,경,일식)에 단가적용율 적용하기"),
|
|
("generate_unit_prices", "적용율 적용된 일위대가/산출근거 생성하기"),
|
|
("separate_same_code", "계약내역내 동일코드의 계약단가 개별생성"),
|
|
("labor_ratio", "노무비율 적용"),
|
|
("tax_free_material", "비과세자재대(원가계산)에 단가적용율 적용하기"),
|
|
("zero_makes_empty", '단가적용율이 0% 이면 계약단가(W코드)를 "0"化 (공내역 생성)'),
|
|
)
|
|
#: 계산에 아직 안 쓰는 옵션과 그 까닭 — 조용히 무시하지 않고 화면에 적음.
|
|
OPTION_NOT_USED = {
|
|
"labor_ratio": (
|
|
"「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음"
|
|
),
|
|
"tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음",
|
|
}
|
|
_SHEETS_NEED_BASE = "「기초단가에 적용」을 함께 켜야 계약 일위대가·산출근거가 섬"
|
|
|
|
|
|
def _pct(value: Any) -> Decimal | None:
|
|
try:
|
|
number = Decimal(str(value))
|
|
except (InvalidOperation, ValueError):
|
|
return None
|
|
return number if number >= 0 else None
|
|
|
|
|
|
def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
"""저장값과 거른 까닭. 적용률은 0 이상 수 · 비면 100(적용 안 함과 같음)."""
|
|
errors: list[str] = []
|
|
cleaned: dict[str, Any] = {}
|
|
for key, label in RATE_FIELDS:
|
|
raw = values.get(key)
|
|
if raw in (None, ""):
|
|
cleaned[key] = "100"
|
|
continue
|
|
pct = _pct(raw)
|
|
if pct is None:
|
|
errors.append(f"【{label}】 적용률이 0 이상 수가 아님 — {raw}")
|
|
continue
|
|
cleaned[key] = str(pct)
|
|
for key, _ in OPTIONS:
|
|
cleaned[key] = bool(values.get(key))
|
|
cleaned["excluded"] = sorted({str(item) for item in values.get("excluded") or [] if item})
|
|
return cleaned, errors
|
|
|
|
|
|
def _money(value: Any) -> Decimal:
|
|
return Decimal(str(value)) if value not in (None, "") else _ZERO
|
|
|
|
|
|
#: 기초단가 층 — 적용률이 걸리는 카탈로그 종류와 성분(자재 = 재 · 노임 = 노 · 중기 취득가 = 경).
|
|
_BASE_KIND_RATE = {
|
|
PriceKind.MATERIAL: "material_pct",
|
|
PriceKind.LABOR: "labor_pct",
|
|
PriceKind.MACHINE_BASE: "expense_pct",
|
|
}
|
|
|
|
|
|
def contract_book(book: PriceBook, factors: dict[str, Decimal]) -> PriceBook:
|
|
"""「기초단가(재,노,경,일식)에 단가적용율 적용」 — **복사본**의 채택 단가에 적용률을 곱함.
|
|
|
|
그 위 층(중기사용료 X · 단가산출 D · 일위대가 B)은 **다시 조립**해 선다 — 설계와 같은 절사
|
|
(`PriceBook.resolve`). ⚠ 원본 단가표(캐시 공유)는 안 건드림. 일식(W)은 단가 0 이라 그대로.
|
|
"""
|
|
copied = copy.deepcopy(book)
|
|
for title in copied.titles.values():
|
|
key = _BASE_KIND_RATE.get(title.kind)
|
|
if key is None:
|
|
continue
|
|
slot = title.adopted_slot - 1
|
|
if 0 <= slot < len(title.slots) and title.slots[slot] is not None:
|
|
title.slots[slot] = title.slots[slot] * factors[key]
|
|
return copied
|
|
|
|
|
|
#: 계약 호표로 내는 층 — 일위대가(B)와 그 아래 단가산출근거(D).
|
|
_SHEET_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
|
|
|
|
|
|
def _add_sheets(
|
|
contract_build: Any, base_code: str, code: str, sheets: dict[str, dict[str, Any]]
|
|
) -> None:
|
|
"""「적용율 적용된 일위대가/산출근거」 — 설계 본표와 **같은 꼴**(`detail_of`)로 W 코드 한 장씩.
|
|
|
|
그 일위대가가 부르는 산출근거(D)도 따라 냄(W-D-…, 줄끼리 한 장 공유).
|
|
"""
|
|
from B09_Estimation.B09_Estimation_UnitPrice_View import detail_of
|
|
|
|
book = contract_build.book
|
|
stack = [(base_code, code)]
|
|
while stack:
|
|
design_code, sheet_code = stack.pop()
|
|
if sheet_code in sheets:
|
|
continue
|
|
sheets[sheet_code] = {
|
|
**detail_of(contract_build, design_code),
|
|
"code": sheet_code,
|
|
"design_code": design_code,
|
|
}
|
|
for detail in book.details.get(design_code, []):
|
|
ref = book.titles.get(detail.ref_code)
|
|
if ref is not None and ref.kind in _SHEET_KINDS and detail.ref_code != design_code:
|
|
stack.append((detail.ref_code, f"W-{detail.ref_code}"))
|
|
|
|
|
|
def contract_bill(
|
|
bill_rows: list[dict[str, Any]],
|
|
settings: dict[str, Any],
|
|
build: Any = None,
|
|
) -> dict[str, Any]:
|
|
"""설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭 · 계약 호표.
|
|
|
|
`build` = 설계 일위대가 조립본(`UnitPriceBuild` — 캐시 공유본이라 **안 고침**). 「기초단가에
|
|
적용」이 켜진 때만 씀 — 단가표 복사본(`contract_book`)을 다시 조립.
|
|
|
|
⚠ 0% = 0 원(2026-09-14 브레인 판정) — 「적용 안 함」은 적용제외 체크가 맡음. 「공내역 생성」은
|
|
그 0 원 줄에 W 코드 「공내역」 이름을 붙이는 것뿐(끄면 그냥 0 원 줄). 표본 없음 — 확인 대기.
|
|
"""
|
|
rates = {key: _pct(settings.get(key, "100")) or _ZERO for key, _ in RATE_FIELDS}
|
|
zero_empty = bool(settings.get("zero_makes_empty"))
|
|
separate = bool(settings.get("separate_same_code"))
|
|
excluded = {str(item) for item in settings.get("excluded") or []}
|
|
|
|
def factor(key: str) -> Decimal:
|
|
return rates[key] / _HUNDRED
|
|
|
|
book = build.book if build is not None else None
|
|
scaled_book = (
|
|
contract_book(book, {key: factor(key) for key, _ in RATE_FIELDS})
|
|
if book is not None and settings.get("apply_to_base_prices")
|
|
else None
|
|
)
|
|
# 조립본의 곁가지(못 붙은 줄 등)는 그대로 읽고 단가표만 계약 복사본으로 — 얕은 복사.
|
|
contract_build = replace(build, book=scaled_book) if scaled_book is not None else None
|
|
sheets: dict[str, dict[str, Any]] = {}
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
design = Money3()
|
|
contract = Money3()
|
|
for source in bill_rows:
|
|
row = dict(source)
|
|
if row.get("is_group") or not row.get("in_bill", True):
|
|
rows.append(row)
|
|
continue
|
|
unit_design = Money3(
|
|
material=_money(row.get("unit_material_krw")),
|
|
labor=_money(row.get("unit_labor_krw")),
|
|
expense=_money(row.get("unit_expense_krw")),
|
|
)
|
|
quantity = row.get("quantity")
|
|
if quantity in (None, "") or row.get("unit_material_krw") is None:
|
|
row.update(contract_note="설계 단가가 안 선 줄 — 계약단가도 못 섬", contract_code="")
|
|
rows.append(row)
|
|
continue
|
|
qty = Decimal(str(quantity))
|
|
design_line = bill_line(unit_design, qty)
|
|
design += design_line
|
|
base_code = str(row.get("price_code") or row.get("code") or "")
|
|
if str(row.get("item_no")) in excluded:
|
|
unit, code, note = unit_design, base_code, "적용 제외 — 설계 단가 그대로"
|
|
else:
|
|
unit, note = _contract_unit(row, unit_design, factor, book, scaled_book)
|
|
suffix = f"@{row.get('item_no')}" if separate else ""
|
|
code = f"W-{base_code}{suffix}"
|
|
if unit.total == 0 and zero_empty:
|
|
note = "공내역 — 적용률 0%"
|
|
if (
|
|
contract_build is not None
|
|
and settings.get("generate_unit_prices")
|
|
and note.startswith("기초단가 적용")
|
|
and scaled_book.titles[base_code].kind in _SHEET_KINDS
|
|
):
|
|
_add_sheets(contract_build, base_code, code, sheets)
|
|
line = bill_line(unit, qty)
|
|
contract += line
|
|
row.update(
|
|
contract_code=code,
|
|
contract_unit_material_krw=str(unit.material),
|
|
contract_unit_labor_krw=str(unit.labor),
|
|
contract_unit_expense_krw=str(unit.expense),
|
|
contract_unit_price_krw=str(unit.total),
|
|
contract_material_krw=str(line.material),
|
|
contract_labor_krw=str(line.labor),
|
|
contract_expense_krw=str(line.expense),
|
|
contract_amount_krw=str(line.total),
|
|
contract_excluded=str(row.get("item_no")) in excluded,
|
|
contract_note=note,
|
|
)
|
|
rows.append(row)
|
|
_group_sums(rows)
|
|
return {
|
|
"rows": rows,
|
|
"totals": {
|
|
"design": _totals(design),
|
|
"contract": _totals(contract),
|
|
"ratio_pct": {
|
|
part: str(
|
|
(getattr(contract, part) / getattr(design, part) * _HUNDRED).quantize(
|
|
Decimal("0.001")
|
|
)
|
|
)
|
|
if getattr(design, part)
|
|
else None
|
|
for part in ("material", "labor", "expense")
|
|
},
|
|
},
|
|
"options_not_used": {
|
|
**{k: v for k, v in OPTION_NOT_USED.items() if settings.get(k)},
|
|
**(
|
|
{"generate_unit_prices": _SHEETS_NEED_BASE}
|
|
if settings.get("generate_unit_prices") and not settings.get("apply_to_base_prices")
|
|
else {}
|
|
),
|
|
},
|
|
# 「적용율 적용된 일위대가/산출근거 생성」 — 기초단가 적용이 켜진 때만
|
|
# (성분 곱셈에는 다시 조립할 호표가 없음).
|
|
"unit_price_sheets": list(sheets.values()),
|
|
}
|
|
|
|
|
|
def _contract_unit(
|
|
row: dict[str, Any],
|
|
unit_design: Money3,
|
|
factor,
|
|
book: PriceBook | None,
|
|
scaled_book: PriceBook | None,
|
|
) -> tuple[Money3, str]:
|
|
"""계약 성분 단가와 비고. 기초단가 적용이면 단가표를 다시 조립, 아니면 성분에 곱함.
|
|
|
|
⚠ 설계 단가가 단가표 조립값과 다르면(할증·수동 단가·구조물도 호표) 기초단가로 못 풂 —
|
|
성분 곱셈으로 떨어지고 그 사실을 비고에 적음(조용히 틀린 값을 세우지 않음).
|
|
"""
|
|
scaled = Money3(
|
|
material=unit_design.material * factor("material_pct"),
|
|
labor=unit_design.labor * factor("labor_pct"),
|
|
expense=unit_design.expense * factor("expense_pct"),
|
|
).floored(Decimal(1))
|
|
if scaled_book is None or book is None:
|
|
return scaled, ""
|
|
unit, why = reassembled_unit(str(row.get("price_code") or ""), unit_design, book, scaled_book)
|
|
if unit is None:
|
|
return scaled, f"기초단가로 못 풂({why}) — 성분에 적용"
|
|
return unit, "기초단가 적용 — 단가표 다시 조립"
|
|
|
|
|
|
def reassembled_unit(
|
|
code: str, unit_design: Money3, book: PriceBook, new_book: PriceBook
|
|
) -> tuple[Money3 | None, str]:
|
|
"""고친 단가표 복사본으로 그 줄 단가를 다시 조립 — 못 풀면 (None, 까닭).
|
|
|
|
⚠ 설계 단가가 설계 단가표 조립값과 같을 때만 — 다르면(할증·수동 단가·구조물도 호표)
|
|
다시 조립한 값이 그 줄 단가가 아님(계약·실행예산 공용).
|
|
"""
|
|
if code not in book.titles:
|
|
return None, "단가표 밖 코드"
|
|
try:
|
|
if book.resolve(code).floored(Decimal(1)) != unit_design:
|
|
return None, "설계 단가가 단가표 조립값과 다름 — 할증·수동 단가"
|
|
return new_book.resolve(code).floored(Decimal(1)), ""
|
|
except PriceBookError as error:
|
|
return None, str(error)
|
|
|
|
|
|
def _totals(money: Money3) -> dict[str, str]:
|
|
return {
|
|
"material_krw": str(money.material),
|
|
"labor_krw": str(money.labor),
|
|
"expense_krw": str(money.expense),
|
|
"total_krw": str(money.total),
|
|
}
|
|
|
|
|
|
def _group_sums(rows: list[dict[str, Any]], stage: str = "contract") -> None:
|
|
"""묶음 줄 단계 금액(`{stage}_…_krw`) — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴)."""
|
|
for group in rows:
|
|
if not group.get("is_group"):
|
|
continue
|
|
prefix = f"{group.get('item_no')}."
|
|
children = [
|
|
r
|
|
for r in rows
|
|
if not r.get("is_group")
|
|
and str(r.get("item_no", "")).startswith(prefix)
|
|
and r.get(f"{stage}_amount_krw") is not None
|
|
]
|
|
for part in ("material", "labor", "expense", "amount"):
|
|
group[f"{stage}_{part}_krw"] = str(
|
|
sum((Decimal(r[f"{stage}_{part}_krw"]) for r in children), _ZERO)
|
|
)
|