feat(b09): 계약 단계 2벌 — 기초단가 적용(단가표 복사본 다시 조립) · 계약 일위대가/산출근거 W 호표 · 0% = 0 원
- 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변 - 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭 - 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만 - 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름 - 시험 11건 · 전체 1699 통과(골든셋 포함) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -15,12 +15,14 @@
|
||||
|
||||
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
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceBookError, PriceKind
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
_HUNDRED = Decimal(100)
|
||||
@@ -46,15 +48,12 @@ OPTIONS: tuple[tuple[str, str], ...] = (
|
||||
)
|
||||
#: 계산에 아직 안 쓰는 옵션과 그 까닭 — 조용히 무시하지 않고 화면에 적음.
|
||||
OPTION_NOT_USED = {
|
||||
"apply_to_base_prices": (
|
||||
"기초단가(자재·노임·중기) 수준 적용은 다음 차례 — 지금은 일위대가 성분에 적용"
|
||||
),
|
||||
"generate_unit_prices": "계약 일위대가·산출근거 표 생성은 다음 차례",
|
||||
"labor_ratio": (
|
||||
"「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음"
|
||||
),
|
||||
"tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음",
|
||||
}
|
||||
_SHEETS_NEED_BASE = "「기초단가에 적용」을 함께 켜야 계약 일위대가·산출근거가 섬"
|
||||
|
||||
|
||||
def _pct(value: Any) -> Decimal | None:
|
||||
@@ -89,11 +88,73 @@ 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`) → 계약내역 줄 · 합계 · 까닭.
|
||||
#: 기초단가 층 — 적용률이 걸리는 카탈로그 종류와 성분(자재 = 재 · 노임 = 노 · 중기 취득가 = 경).
|
||||
_BASE_KIND_RATE = {
|
||||
PriceKind.MATERIAL: "material_pct",
|
||||
PriceKind.LABOR: "labor_pct",
|
||||
PriceKind.MACHINE_BASE: "expense_pct",
|
||||
}
|
||||
|
||||
⚠ 0% 처리(판정 대기): 「공내역 생성」을 켜면 그 성분이 0 원(세 성분 모두 0 이면 공내역 줄).
|
||||
끄면 0% 를 「적용 안 함」으로 봄 — STmate 가 따로 옵션을 둔 까닭으로 읽은 것(표본 없음).
|
||||
|
||||
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"))
|
||||
@@ -101,10 +162,17 @@ def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) ->
|
||||
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
|
||||
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()
|
||||
@@ -131,14 +199,18 @@ def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) ->
|
||||
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))
|
||||
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}"
|
||||
note = "공내역 — 적용률 0%" if unit.total == 0 and zero_empty else ""
|
||||
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(
|
||||
@@ -172,10 +244,50 @@ def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) ->
|
||||
for part in ("material", "labor", "expense")
|
||||
},
|
||||
},
|
||||
"options_not_used": {k: v for k, v in OPTION_NOT_USED.items() if settings.get(k)},
|
||||
"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, ""
|
||||
code = str(row.get("price_code") or "")
|
||||
if code not in book.titles:
|
||||
return scaled, "기초단가로 못 풂(단가표 밖 코드) — 성분에 적용"
|
||||
try:
|
||||
if book.resolve(code).floored(Decimal(1)) != unit_design:
|
||||
return scaled, "설계 단가가 단가표 조립값과 다름(할증·수동 단가) — 성분에 적용"
|
||||
return scaled_book.resolve(code).floored(Decimal(1)), "기초단가 적용 — 단가표 다시 조립"
|
||||
except PriceBookError as error:
|
||||
return scaled, f"기초단가로 못 풂({error}) — 성분에 적용"
|
||||
|
||||
|
||||
def _totals(money: Money3) -> dict[str, str]:
|
||||
return {
|
||||
"material_krw": str(money.material),
|
||||
|
||||
@@ -35,7 +35,7 @@ async def _root(project_id: UUID) -> str | None:
|
||||
@router.get("/{project_id}/estimation/contract")
|
||||
async def get_contract(project_id: UUID) -> JSONResponse:
|
||||
"""계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션."""
|
||||
from B09_Estimation.B09_Estimation_Router import get_bill
|
||||
from B09_Estimation.B09_Estimation_Router import _build_for, get_bill
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
root = await _root(project_id)
|
||||
@@ -49,7 +49,9 @@ async def get_contract(project_id: UUID) -> JSONResponse:
|
||||
if response.status_code != 200 or "rows" not in bill:
|
||||
return JSONResponse(status_code=response.status_code or 502, content=bill)
|
||||
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
||||
result = contract_bill(bill["rows"], stored)
|
||||
# 조립본은 캐시 공유본 — `contract_bill` 이 단가표 복사본에만 적용률을 얹음(설계 불변).
|
||||
build = await _build_for(project_id) if stored.get("apply_to_base_prices") else None
|
||||
result = contract_bill(bill["rows"], stored, build=build)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
|
||||
@@ -40,10 +40,34 @@ interface Money {
|
||||
total_krw: string;
|
||||
}
|
||||
|
||||
/** 계약 호표 한 장 — 설계 본표(`detail_of`)와 같은 꼴 + 계약 코드(W-). */
|
||||
interface ContractSheet {
|
||||
code: string;
|
||||
design_code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
material: string;
|
||||
labor: string;
|
||||
expense: string;
|
||||
total: string;
|
||||
rows: {
|
||||
name: string;
|
||||
spec: string;
|
||||
unit?: string;
|
||||
quantity: string;
|
||||
material: string;
|
||||
labor: string;
|
||||
expense: string;
|
||||
total: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ContractDto {
|
||||
status: string;
|
||||
message?: string;
|
||||
rows: ContractRow[];
|
||||
unit_price_sheets: ContractSheet[];
|
||||
totals: { design: Money; contract: Money; ratio_pct: Record<string, string | null> };
|
||||
options_not_used: Record<string, string>;
|
||||
settings: Record<string, string | boolean | string[]>;
|
||||
@@ -75,6 +99,8 @@ function injectStyles(): void {
|
||||
.b09ct__rates input { width: 4.5em; text-align: right; }
|
||||
.b09ct__option { display: flex; gap: 4px; align-items: flex-start; }
|
||||
.b09ct__totals { font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.b09ct__sheets { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; font-size: 12px; }
|
||||
.b09ct__sheets summary { cursor: pointer; }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
@@ -261,10 +287,51 @@ function drawBody(ctx: B09TabContext, data: ContractDto): void {
|
||||
table.append(tr);
|
||||
}
|
||||
scroll.append(table);
|
||||
if (data.unit_price_sheets.length) scroll.append(drawSheets(data.unit_price_sheets));
|
||||
wrap.append(scroll);
|
||||
ctx.body.append(wrap);
|
||||
}
|
||||
|
||||
/** 「적용율 적용된 일위대가/산출근거」 — 설계 호표는 그대로, 계약 호표(W-)만 따로 펼침. */
|
||||
function drawSheets(sheets: ContractSheet[]): HTMLElement {
|
||||
const box = el("div", "b09ct__sheets");
|
||||
box.append(el("strong", "", `계약 일위대가·산출근거 ${sheets.length}장 — 설계 호표는 그대로`));
|
||||
for (const sheet of sheets) {
|
||||
const details = el("details");
|
||||
details.append(
|
||||
el(
|
||||
"summary",
|
||||
"",
|
||||
`${sheet.code} ${sheet.name} ${sheet.spec} (설계 ${sheet.design_code}) — ` +
|
||||
`재 ${won(sheet.material)} · 노 ${won(sheet.labor)} · 경 ${won(sheet.expense)} · 계 ${won(sheet.total)}`,
|
||||
),
|
||||
);
|
||||
const table = el("table", "b09ct__table");
|
||||
const head = el("tr");
|
||||
for (const label of ["명칭", "규격", "단위", "수량", "재료비", "노무비", "경비", "합계"]) {
|
||||
head.append(el("th", "", label));
|
||||
}
|
||||
table.append(head);
|
||||
for (const row of sheet.rows) {
|
||||
const tr = el("tr");
|
||||
tr.append(
|
||||
el("td", "", row.name),
|
||||
el("td", "", row.spec ?? ""),
|
||||
el("td", "", row.unit ?? ""),
|
||||
el("td", "num", row.quantity),
|
||||
el("td", "num", won(row.material)),
|
||||
el("td", "num", won(row.labor)),
|
||||
el("td", "num", won(row.expense)),
|
||||
el("td", "num", won(row.total)),
|
||||
);
|
||||
table.append(tr);
|
||||
}
|
||||
details.append(table);
|
||||
box.append(details);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function render(ctx: B09TabContext): void {
|
||||
injectStyles();
|
||||
if (!ctx.projectId) {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜
|
||||
② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다)
|
||||
③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음
|
||||
④ 0% + 「공내역 생성」 = 0 원 공내역 줄 · 옵션 끄면 0% = 적용 안 함
|
||||
④ 0% = 0 원(브레인 판정) · 「공내역 생성」 켜면 그 줄에 공내역 이름
|
||||
⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌
|
||||
⑥ 묶음 줄 합 · 틀린 적용률 거름
|
||||
⑦ 2벌 — 기초단가 적용은 단가표 **복사본**만(설계 단가표 불변) · 못 푸는 줄은 까닭 ·
|
||||
계약 호표(W-B·W-D)는 기초단가 적용과 함께만 섬
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +22,13 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Contract import clean_settings, contract_bill # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
|
||||
PriceBook,
|
||||
PriceDetail,
|
||||
PriceKind,
|
||||
PriceTitle,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402
|
||||
|
||||
ROWS = [
|
||||
{"item_no": "1", "is_group": True, "in_bill": True},
|
||||
@@ -88,8 +97,8 @@ def test_0퍼센트_공내역_옵션() -> None:
|
||||
zero = {"labor_pct": "0", "material_pct": "0", "expense_pct": "0"}
|
||||
empty = _rows({**zero, "zero_makes_empty": True})["1.1"]
|
||||
assert empty["contract_amount_krw"] == "0" and "공내역" in empty["contract_note"]
|
||||
kept = _rows(zero)["1.1"]
|
||||
assert kept["contract_unit_price_krw"] == "6009" # 옵션 끄면 0% = 적용 안 함
|
||||
plain = _rows(zero)["1.1"]
|
||||
assert plain["contract_amount_krw"] == "0" and plain["contract_note"] == "" # 그냥 0 원 줄
|
||||
|
||||
|
||||
def test_동일코드_개별생성() -> None:
|
||||
@@ -112,3 +121,91 @@ def test_묶음_줄_합과_합계_비율() -> None:
|
||||
def test_틀린_적용률은_거른다() -> None:
|
||||
cleaned, errors = clean_settings({"labor_pct": "-3", "material_pct": "abc", "expense_pct": ""})
|
||||
assert len(errors) == 2 and cleaned["expense_pct"] == "100"
|
||||
|
||||
|
||||
# ── 2벌 — 「기초단가에 적용」 · 「적용율 적용된 일위대가/산출근거 생성」 ──────────────
|
||||
|
||||
|
||||
def _build():
|
||||
"""자재 1,001 · 노임 2,003 · 산출근거 D-T(노임 0.5) · 일위대가 B-T(자재 2 + D-T 1)."""
|
||||
book = PriceBook()
|
||||
for code, kind, price in (
|
||||
("M-A", PriceKind.MATERIAL, "1001"),
|
||||
("L-B", PriceKind.LABOR, "2003"),
|
||||
):
|
||||
slots = [None] * 6
|
||||
slots[5] = Decimal(price)
|
||||
book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots))
|
||||
book.add_title(PriceTitle(code="D-T", kind=PriceKind.PRICE_BASIS, name="산출근거"))
|
||||
book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="일위대가"))
|
||||
book.add_detail(PriceDetail("D-T", "L-B", Decimal("0.5")))
|
||||
book.add_detail(PriceDetail("B-T", "M-A", Decimal(2)))
|
||||
book.add_detail(PriceDetail("B-T", "D-T", Decimal(1)))
|
||||
return UnitPriceBuild(book=book)
|
||||
|
||||
|
||||
def _line(item_no: str, code: str, material: str, labor: str) -> dict:
|
||||
return {
|
||||
"item_no": item_no,
|
||||
"is_group": False,
|
||||
"in_bill": True,
|
||||
"quantity": "3",
|
||||
"price_code": code,
|
||||
"unit_material_krw": material,
|
||||
"unit_labor_krw": labor,
|
||||
"unit_expense_krw": "0",
|
||||
}
|
||||
|
||||
|
||||
BASE_ROWS = [
|
||||
_line("2.1", "B-T", "2002", "1001"), # 설계 단가 = 단가표 조립값
|
||||
_line("2.2", "B-T", "2500", "1001"), # 수동·할증으로 조립값과 다름
|
||||
_line("2.3", "Z-없음", "100", "0"), # 단가표 밖
|
||||
]
|
||||
BASE_ON = {
|
||||
"material_pct": "90",
|
||||
"labor_pct": "80",
|
||||
"apply_to_base_prices": True,
|
||||
"generate_unit_prices": True,
|
||||
}
|
||||
|
||||
|
||||
def test_기초단가_적용은_복사본만_고치고_다시_조립() -> None:
|
||||
build = _build()
|
||||
before = (build.book.resolve("B-T"), copy.deepcopy(build.book.titles["M-A"].slots))
|
||||
result = contract_bill(BASE_ROWS, BASE_ON, build=build)
|
||||
assert (build.book.resolve("B-T"), build.book.titles["M-A"].slots) == before # 설계 단가표 불변
|
||||
rows = {row["item_no"]: row for row in result["rows"]}
|
||||
# 자재 900.9 × 2 = 1801.8 → 1801 · 노임 1602.4 × 0.5 = 801.2 → D 801 → B 801
|
||||
# (성분에 곱했으면 노무 1001 × 0.8 = 800.8 → 800 — 층을 다시 조립해야 801)
|
||||
assert (rows["2.1"]["contract_unit_material_krw"], rows["2.1"]["contract_unit_labor_krw"]) == (
|
||||
"1801",
|
||||
"801",
|
||||
)
|
||||
assert rows["2.1"]["contract_note"].startswith("기초단가 적용")
|
||||
|
||||
|
||||
def test_기초단가로_못_푸는_줄은_성분에_곱하고_까닭을_적는다() -> None:
|
||||
rows = {r["item_no"]: r for r in contract_bill(BASE_ROWS, BASE_ON, build=_build())["rows"]}
|
||||
assert (
|
||||
rows["2.2"]["contract_unit_material_krw"] == "2250"
|
||||
and "조립값과 다름" in (rows["2.2"]["contract_note"])
|
||||
)
|
||||
assert "단가표 밖" in rows["2.3"]["contract_note"]
|
||||
|
||||
|
||||
def test_계약_호표는_W코드로_따로_서고_산출근거도_따라_선다() -> None:
|
||||
sheets = contract_bill(BASE_ROWS, BASE_ON, build=_build())["unit_price_sheets"]
|
||||
by_code = {sheet["code"]: sheet for sheet in sheets}
|
||||
assert set(by_code) == {"W-B-T", "W-D-T"}
|
||||
assert (by_code["W-B-T"]["design_code"], by_code["W-B-T"]["total"]) == ("B-T", "2602")
|
||||
assert by_code["W-D-T"]["labor"] == "801"
|
||||
|
||||
|
||||
def test_호표_생성은_기초단가_적용이_함께_켜져야_선다() -> None:
|
||||
only_sheets = {**BASE_ON, "apply_to_base_prices": False}
|
||||
result = contract_bill(BASE_ROWS, only_sheets, build=_build())
|
||||
assert result["unit_price_sheets"] == []
|
||||
assert "generate_unit_prices" in result["options_not_used"]
|
||||
off = contract_bill(BASE_ROWS, {**BASE_ON, "generate_unit_prices": False}, build=_build())
|
||||
assert off["unit_price_sheets"] == []
|
||||
|
||||
Reference in New Issue
Block a user