diff --git a/B09_Estimation/B09_Estimation_Contract.py b/B09_Estimation/B09_Estimation_Contract.py new file mode 100644 index 00000000..5eacf158 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Contract.py @@ -0,0 +1,204 @@ +"""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) + ) diff --git a/B09_Estimation/B09_Estimation_Router_Contract.py b/B09_Estimation/B09_Estimation_Router_Contract.py new file mode 100644 index 00000000..a5ab9e1b --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Contract.py @@ -0,0 +1,95 @@ +"""B09 계약 단계 탭 API — 당초설계 → 계약내역 (PLAN 12장 · 랩탑 메인). + +⚠ 설계 내역을 **읽기만** 한다(`/estimation/bill` 결과를 복사해 적용률을 얹음). 저장은 + `estimation.contract` 한 칸 — 설계 내역·원가계산서 저장본은 안 건드린다. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_Contract import ( + OPTIONS, + RATE_FIELDS, + SETTINGS_KEY, + clean_settings, + contract_bill, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Contract"]) + + +async def _root(project_id: UUID) -> str | None: + from B09_Estimation.B09_Estimation_Router import _project_root_of + + return await _project_root_of(project_id) + + +@router.get("/{project_id}/estimation/contract") +async def get_contract(project_id: UUID) -> JSONResponse: + """계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션.""" + from B09_Estimation.B09_Estimation_Router import get_bill + from common_util.common_util_project_settings import estimation_settings + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + response = await get_bill(project_id) + bill = json.loads(bytes(response.body).decode("utf-8")) + 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) + return JSONResponse( + content={ + "status": "success", + **result, + "settings": stored, + "fields": { + "rates": [{"key": k, "label": label} for k, label in RATE_FIELDS], + "options": [{"key": k, "label": label} for k, label in OPTIONS], + }, + "bill_missing_count": len((bill.get("summary") or {}).get("missing") or []), + "limit_note": ( + "계약 표본 0건 — 구조가 서는지까지만 확인됨 · 원 단위 값은 표본이 생기면 대조" + ), + } + ) + + +@router.put("/{project_id}/estimation/contract") +async def put_contract(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """적용률·옵션·적용 제외 줄 저장 — 틀린 칸이 있으면 아무것도 안 저장.""" + from common_util.common_util_project_settings import save_section + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + cleaned, errors = clean_settings(body) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "message": " · ".join(errors), "errors": errors}, + ) + try: + save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,)) + except Exception: + logger.exception("B09 계약 조건 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "계약 조건을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "settings": cleaned}) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts b/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts new file mode 100644 index 00000000..6c1ee501 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts @@ -0,0 +1,300 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Contract.ts + * 계약내역 탭 — STmate 「당초설계 → 계약내역 변환등록」(wM_Mk_Cont)을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 단가 적용율 【 노 】【 재 】【 경 】 % · 적용 옵션 여섯(화면 표기 그대로) · [저장]. + * - 본문 = 공종번호 · 명칭 · 규격 · 금액(설계) · 계약 단가·금액 · **적용제외** · 계약단가 코드. + * - ⚠ 값은 서버(`/estimation/contract`)가 설계 내역을 복사해 셈 — 설계는 안 바뀜. + * - ⚠ 계약 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface ContractRow { + item_no: string; + name: string; + spec: string; + unit: string; + quantity: string | null; + is_group: boolean; + in_bill: boolean; + amount_krw: string | null; + contract_unit_price_krw?: string; + contract_amount_krw?: string; + contract_code?: string; + contract_excluded?: boolean; + contract_note?: string; +} + +interface Money { + material_krw: string; + labor_krw: string; + expense_krw: string; + total_krw: string; +} + +interface ContractDto { + status: string; + message?: string; + rows: ContractRow[]; + totals: { design: Money; contract: Money; ratio_pct: Record }; + options_not_used: Record; + settings: Record; + fields: { + rates: { key: string; label: string }[]; + options: { key: string; label: string }[]; + }; + bill_missing_count: number; + limit_note: string; +} + +const STYLE_ID = "b09-contract-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09ct { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09ct__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09ct__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09ct__scroll { flex: 1; overflow: auto; min-height: 0; } +.b09ct__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09ct__table th, .b09ct__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09ct__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09ct__table tr.is-group td { font-weight: 600; } +.b09ct__table tr.is-excluded td { color: var(--color-text-secondary); } +.b09ct__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.b09ct__rates { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.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; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function won(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */ +const drafts = new Map>(); + +async function fetchContract(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`, + { credentials: "include" }, + ); + const body = (await response.json()) as ContractDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveContract( + projectId: string, + values: Record, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }, + ); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +function drawPanel(ctx: B09TabContext, data: ContractDto, reload: () => void): void { + const projectId = ctx.projectId as string; + const draft = drafts.get(projectId) ?? { ...data.settings }; + drafts.set(projectId, draft); + const box = el("div", "b09ct__panel"); + box.append(el("strong", "", "단가 적용율")); + const rates = el("div", "b09ct__rates"); + for (const field of data.fields.rates) { + const label = el("label", "b09ct__rates"); + label.append(el("span", "", `【 ${field.label} 】`)); + const input = el("input"); + input.type = "number"; + input.min = "0"; + input.step = "0.001"; + input.value = String(draft[field.key] ?? "100"); + input.addEventListener("input", () => (draft[field.key] = input.value)); + label.append(input, el("span", "", "%")); + rates.append(label); + } + box.append(rates); + for (const option of data.fields.options) { + const row = el("label", "b09ct__option"); + const check = el("input"); + check.type = "checkbox"; + check.checked = Boolean(draft[option.key]); + check.addEventListener("change", () => (draft[option.key] = check.checked)); + row.append(check, el("span", "", option.label)); + box.append(row); + const unused = data.options_not_used[option.key]; + if (unused) box.append(el("span", "b09ct__warn", `⚠ ${unused}`)); + } + box.append( + createButton({ + label: "저장", + onClick: async () => { + try { + await saveContract(projectId, draft); + drafts.delete(projectId); + showToast("계약 조건 저장 — 설계 내역은 그대로", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + const t = data.totals; + const totals = el("div", "b09ct__totals"); + for (const [label, part] of [ + ["재료비", "material"], + ["노무비", "labor"], + ["경비", "expense"], + ] as const) { + totals.append( + el( + "div", + "", + `${label} ${won(t.design[`${part}_krw`])} → ${won(t.contract[`${part}_krw`])}` + + (t.ratio_pct[part] ? ` (${t.ratio_pct[part]}%)` : ""), + ), + ); + } + totals.append( + el("div", "", `직접공사비 ${won(t.design.total_krw)} → ${won(t.contract.total_krw)}`), + ); + box.append(totals); + ctx.panel.append(box); +} + +function drawBody(ctx: B09TabContext, data: ContractDto): void { + const projectId = ctx.projectId as string; + const draft = drafts.get(projectId) ?? { ...data.settings }; + drafts.set(projectId, draft); + const excluded = new Set((draft.excluded as string[] | undefined) ?? []); + const wrap = el("div", "b09ct"); + wrap.append(el("div", "b09ct__warn", `⚠ ${data.limit_note}`)); + if (data.bill_missing_count) { + wrap.append( + el( + "div", + "b09ct__warn", + `⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 계약단가도 못 섬`, + ), + ); + } + wrap.append( + el("div", "b09ct__meta", "적용율을 제외할(ex 관급자재대..) 공정을 선택 — [저장]하면 반영"), + ); + const scroll = el("div", "b09ct__scroll"); + 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 data.rows) { + const tr = el("tr", row.is_group ? "is-group" : row.contract_excluded ? "is-excluded" : ""); + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", row.quantity ?? ""), + el("td", "num", won(row.amount_krw)), + el("td", "num", won(row.contract_unit_price_krw)), + el("td", "num", won(row.contract_amount_krw)), + ); + const cell = el("td"); + if (!row.is_group && row.in_bill) { + const check = el("input"); + check.type = "checkbox"; + check.checked = excluded.has(row.item_no); + check.addEventListener("change", () => { + if (check.checked) excluded.add(row.item_no); + else excluded.delete(row.item_no); + draft.excluded = [...excluded]; + }); + cell.append(check); + } + tr.append(cell, el("td", "", row.contract_code ?? ""), el("td", "", row.contract_note ?? "")); + table.append(tr); + } + scroll.append(table); + wrap.append(scroll); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09ct__meta", "프로젝트를 고르세요")); + return; + } + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09ct__meta", "계약내역 계산 중…")); + ctx.panel.replaceChildren(); + fetchContract(ctx.projectId as string) + .then((data) => { + ctx.body.replaceChildren(); + drawPanel(ctx, data, load); + drawBody(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09ct__warn", + `계약내역을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +export const contractTab: B09Tab = { + key: "contract", + label: () => L("B09_Estimation_Tab_Contract"), + render, +}; diff --git a/main.py b/main.py index 7d2edba6..b3cc9ca7 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_r from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router @@ -640,6 +641,7 @@ app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b08_structure_sheet_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) app.include_router(b09_cost_sheet_router, dependencies=protected_with_company) +app.include_router(b09_contract_router, dependencies=protected_with_company) app.include_router(b09_edits_router, dependencies=protected_with_company) app.include_router(b09_factors_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/test_b09_contract.py b/resources/tester/test_b09_contract.py new file mode 100644 index 00000000..3be35c0a --- /dev/null +++ b/resources/tester/test_b09_contract.py @@ -0,0 +1,114 @@ +"""계약 단계 — 설계 → 계약내역 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정). + +⚠ 값이 맞는지는 못 잰다 — 계약 표본 0건(STmate 27번 §9). 여기서 재는 것: + ① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜 + ② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다) + ③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음 + ④ 0% + 「공내역 생성」 = 0 원 공내역 줄 · 옵션 끄면 0% = 적용 안 함 + ⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌 + ⑥ 묶음 줄 합 · 틀린 적용률 거름 +""" + +from __future__ import annotations + +import copy +import sys +from decimal import Decimal +from pathlib import Path + +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 + +ROWS = [ + {"item_no": "1", "is_group": True, "in_bill": True}, + { + "item_no": "1.1", + "is_group": False, + "in_bill": True, + "quantity": "12.5", + "price_code": "B-FP-09-03-02", + "unit_material_krw": "1001", + "unit_labor_krw": "2003", + "unit_expense_krw": "3005", + }, + { + "item_no": "1.2", + "is_group": False, + "in_bill": True, + "quantity": "3", + "price_code": "B-FP-09-03-02", + "unit_material_krw": "1001", + "unit_labor_krw": "2003", + "unit_expense_krw": "3005", + }, + { + "item_no": "1.3", + "is_group": False, + "in_bill": True, + "quantity": "2", + "price_code": "M-관급자재", + "unit_material_krw": "50000", + "unit_labor_krw": "0", + "unit_expense_krw": "0", + }, +] + + +def _rows(settings: dict) -> dict[str, dict]: + return {row["item_no"]: row for row in contract_bill(ROWS, settings)["rows"]} + + +def test_100퍼센트면_설계와_같고_입력은_안_바뀐다() -> None: + before = copy.deepcopy(ROWS) + result = contract_bill(ROWS, clean_settings({})[0]) + assert ROWS == before + assert result["totals"]["design"] == result["totals"]["contract"] + + +def test_노재경을_따로_곱하고_성분_단가를_원_미만_절사() -> None: + row = _rows({"labor_pct": "80", "material_pct": "90", "expense_pct": "70"})["1.1"] + assert ( + row["contract_unit_material_krw"], + row["contract_unit_labor_krw"], + row["contract_unit_expense_krw"], + ) == ("900", "1602", "2103") # 900.9→900 · 1602.4→1602 · 2103.5→2103 + assert row["contract_material_krw"] == "11250" # 12.5 × 900 + assert row["contract_code"] == "W-B-FP-09-03-02" + + +def test_적용_제외_줄은_설계_단가_W코드_없음() -> None: + row = _rows({"material_pct": "50", "excluded": ["1.3"]})["1.3"] + assert row["contract_unit_material_krw"] == "50000" and row["contract_excluded"] is True + assert not row["contract_code"].startswith("W-") + + +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% = 적용 안 함 + + +def test_동일코드_개별생성() -> None: + shared = _rows({"material_pct": "90"}) + assert shared["1.1"]["contract_code"] == shared["1.2"]["contract_code"] + separate = _rows({"material_pct": "90", "separate_same_code": True}) + assert separate["1.1"]["contract_code"] != separate["1.2"]["contract_code"] + + +def test_묶음_줄_합과_합계_비율() -> None: + result = contract_bill(ROWS, {"material_pct": "90", "labor_pct": "90", "expense_pct": "90"}) + group = result["rows"][0] + children = [r for r in result["rows"] if not r.get("is_group")] + assert Decimal(group["contract_amount_krw"]) == sum( + Decimal(r["contract_amount_krw"]) for r in children + ) + assert result["totals"]["ratio_pct"]["labor"] is not 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" diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index d0d3d7d6..70a5edf9 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -8,4 +8,5 @@ export const ui_locales_b4 = { B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"], + B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"], } as const;