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:
2026-09-14 04:41:10 +09:00
co-authored by Claude Opus 5
parent 6a2c5aacae
commit 26097d6922
6 changed files with 716 additions and 0 deletions
+204
View File
@@ -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,
};
+2
View File
@@ -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)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
+114
View File
@@ -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"
+1
View File
@@ -8,4 +8,5 @@
export const ui_locales_b4 = {
B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"],
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
} as const;