- STmate wM_Mk_Cont(27번 §2) 본뜸: 단가 적용율 【노】【재】【경】 · 적용 옵션 여섯(화면 표기 그대로) · 적용제외 공정 - 설계 내역(/estimation/bill) 줄을 복사해 성분마다 적용률 — 절사는 설계 내역 규칙(bill_line) 그대로 · 설계 불변 - W 코드: 동일코드 한 벌(기본) / 개별생성 켜면 줄마다 · 적용 제외 줄은 설계 단가·설계 코드 - 0%: 「공내역 생성」 켜면 0 원 공내역 줄 · 끄면 적용 안 함(판정 대기) - 뜻 미확인 옵션(노무비율)·아직 안 선 옵션(기초단가 적용·일위대가 생성·비과세자재)은 칸만 받고 까닭을 화면에 - GET/PUT /estimation/contract · 탭 파일 B09_Estimation_UI_Tab_Contract.ts(등록은 서브) · 사전 b4 - ⚠ 계약 표본 0건 — 구조가 서는지까지만 시험 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
205 lines
8.7 KiB
Python
205 lines
8.7 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
|
|
|
|
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
|
|
|
|
_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 = {
|
|
"apply_to_base_prices": (
|
|
"기초단가(자재·노임·중기) 수준 적용은 다음 차례 — 지금은 일위대가 성분에 적용"
|
|
),
|
|
"generate_unit_prices": "계약 일위대가·산출근거 표 생성은 다음 차례",
|
|
"labor_ratio": (
|
|
"「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음"
|
|
),
|
|
"tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음",
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) -> dict[str, Any]:
|
|
"""설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭.
|
|
|
|
⚠ 0% 처리(판정 대기): 「공내역 생성」을 켜면 그 성분이 0 원(세 성분 모두 0 이면 공내역 줄).
|
|
끄면 0% 를 「적용 안 함」으로 봄 — STmate 가 따로 옵션을 둔 까닭으로 읽은 것(표본 없음).
|
|
"""
|
|
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:
|
|
pct = rates[key]
|
|
if pct == 0 and not zero_empty:
|
|
return Decimal(1) # 0% = 적용 안 함(공내역 옵션 꺼짐)
|
|
return pct / _HUNDRED
|
|
|
|
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 = 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))
|
|
suffix = f"@{row.get('item_no')}" if separate else ""
|
|
code = f"W-{base_code}{suffix}"
|
|
note = "공내역 — 적용률 0%" if unit.total == 0 and zero_empty else ""
|
|
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)},
|
|
}
|
|
|
|
|
|
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]]) -> None:
|
|
"""묶음 줄 계약 금액 — 아래 줄의 합(설계 내역 `_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("contract_amount_krw") is not None
|
|
]
|
|
for part in ("material", "labor", "expense", "amount"):
|
|
group[f"contract_{part}_krw"] = str(
|
|
sum((Decimal(r[f"contract_{part}_krw"]) for r in children), _ZERO)
|
|
)
|