feat(b09): 계약 단계 1벌 — 노·재·경 단가 적용률 · 행별 적용 제외 · 0% 공내역 · W 코드
- 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
This commit is contained in:
@@ -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)
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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<string, string | null> };
|
||||
options_not_used: Record<string, string>;
|
||||
settings: Record<string, string | boolean | string[]>;
|
||||
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<K extends keyof HTMLElementTagNameMap>(
|
||||
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<string, Record<string, string | boolean | string[]>>();
|
||||
|
||||
async function fetchContract(projectId: string): Promise<ContractDto> {
|
||||
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<string, string | boolean | string[]>,
|
||||
): Promise<void> {
|
||||
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,
|
||||
};
|
||||
@@ -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)
|
||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||
|
||||
@@ -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"
|
||||
@@ -8,4 +8,5 @@
|
||||
|
||||
export const ui_locales_b4 = {
|
||||
B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"],
|
||||
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user