Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-14 04:28:27 +09:00
4 changed files with 278 additions and 17 deletions
@@ -0,0 +1,164 @@
"""원가계산서 4형식 중 나머지 셋 — **서식 차례만** (수공 S · 실적일반 TA · 실적수공 SA).
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12번 정정 반영).
⚠ **금액을 안 낸다** — 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` ·
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다.
**0 원으로 채우지 않는다** — 0 원은 「없다」가 아니라 「공짜」로 읽혀 총액이 조용히 작아진다.
⚠ 35번 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재)은 「확인 대기」로 세운다.
"""
from __future__ import annotations
from typing import Any
#: 줄 상태 — 화면이 그대로 보인다.
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)"
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)"
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)"
TOTAL = "합계 — 위 줄이 다 서야 섬"
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
DIRECT = "설계내역서 합계에서 옴"
_SUGONG_EXPENSES = (
"수도광열비",
"복리후생비",
"소모품·사무용품비",
"여비·교통비·통신비",
"세금·공과금",
"폐기물처리비",
"도서인쇄비",
"지급수수료",
)
FORM_LABELS = {
"general": "일반형식",
"sugong": "수공형식",
"actual_general": "실적일반",
"actual_sugong": "실적수공",
}
def _r(mark: str, label: str, formula: str, status: str, level: int) -> dict[str, Any]:
return {
"key": "",
"mark": mark,
"label": label,
"level": level,
"formula": formula,
"rate_percent": None,
"amount_krw": None,
"note": status,
"total": level == 0,
"blocked": status not in (COMMON, DIRECT),
}
def _sugong() -> list[dict[str, Any]]:
"""수공 S — 12번 §4.1."""
rows = [
_r("1.", "순공사원가(가+나+다)", "", TOTAL, 0),
_r("가.", "순공사비", "", DIRECT, 1),
_r("나.", "간접노무비", "직접노무비 ×", COMMON, 1),
_r("다.", "경 비", "", TOTAL, 1),
_r("1)", "산재보험료", "(직접노무비+간접노무비) ×", COMMON, 2),
_r("2)", "고용보험료", "(직접노무비+간접노무비) ×", COMMON, 2),
_r("3)", "건강보험료", "직접노무비 ×", COMMON, 2),
_r("4)", "장기요양보험료", "건강보험료 ×", COMMON, 2),
_r("5)", "연금보험료", "직접노무비 ×", COMMON, 2),
_r("6)", "퇴직공제부금비", "직접노무비 ×", COMMON, 2),
_r("7)", "산업안전보건관리비", "(재료비+직접노무비+도급자관급자재) × 율", COMMON, 2),
_r("8)", "안전관리비", "", SEPARATE, 2),
_r("9)", "품질관리비", "", SEPARATE, 2),
]
rows += [
_r(f"{10 + i})", name, "(직노·노무·재료 중 고른 밑수) × 율", RATE_B, 2)
for i, name in enumerate(_SUGONG_EXPENSES)
]
rows += [
_r("18)", "공사이행 수수료", "[기초액 + (직공비−기준액) × 율] × 공기", COMMON, 2),
_r("19)", "하도대금 수수료", "(재료비+직노+산출경비) ×", COMMON, 2),
_r("20)", "환 경 보 전 비", "(재료비+직노+산출경비) ×", COMMON, 2),
_r("21)", "건설기계 대여금", "(재료비+직노+산출경비) ×", COMMON, 2),
_r("22)", "석면분담금", "(직노+간노) ×", COMMON, 2),
_r("23)", "임금채권부담금", "(직노+간노) ×", COMMON, 2),
_r(
"2.",
"일반관리비",
"(순공사비+간접노무비+경비) × 율 — 밑수에 수공 경비가 들어 미산출",
RATE_B,
1,
),
_r("3.", "이 윤", "(순공사원가+일반관리비−재료비) × 율 — 밑수 미산출", RATE_B, 1),
_r("4.", "공급가액", "", TOTAL, 0),
_r("5.", "도급공사비", "", TOTAL, 0),
_r("6.", "총공사비", "", TOTAL, 0),
]
return rows
def _actual(sugong: bool) -> list[dict[str, Any]]:
"""실적일반 TA(§5 · 35번 정정) / 실적수공 SA(§6)."""
labor_base = "(직접노무비+간접노무비)" if sugong else "노무비"
rows = [
_r("1.", "직접공사비", "", DIRECT, 0),
_r("2.", "간접공사비", "", TOTAL, 0),
_r("1)", "간접노무비", "직접노무비 ×", RATE_RA, 1),
_r("2)", "산재보험료", f"{labor_base} ×", COMMON, 1),
_r("3)", "고용보험료", f"{labor_base} ×", COMMON, 1),
_r("4)", "건강보험료", "직접노무비 ×", COMMON, 1),
_r("5)", "장기요양보험료", "건강보험료 ×", COMMON, 1),
_r("6)", "연금보험료", "직접노무비 ×", COMMON, 1),
_r("7)", "퇴직공제부금비", "직접노무비 ×", COMMON, 1),
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) ×", RATE_AN_A, 1),
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1),
_r("10)", "품질관리비", "", SEPARATE, 1),
]
if sugong:
rows += [
_r(f"{11 + i})", name, "(직노·노무·재료 중 고른 밑수) × 율", RATE_B, 1)
for i, name in enumerate(_SUGONG_EXPENSES)
]
guarantee_base = "(재료비+직노+산출경비)"
tail = 19
else:
rows.append(_r("11)", "기타경비", "직접공사비 ×", RATE_RA, 1))
guarantee_base = "직접공사비"
tail = 12
rows += [
_r(f"{tail})", "공사이행 수수료", "[기초액 + (직공비−기준액) × 율] × 공기", COMMON, 1),
_r(f"{tail + 1})", "하도대금 수수료", f"{guarantee_base} ×", COMMON, 1),
_r(f"{tail + 2})", "환 경 보 전 비", "(재료비+직노+산출경비) ×", COMMON, 1),
_r(f"{tail + 3})", "건설기계 대여금", f"{guarantee_base} ×", COMMON, 1),
_r(f"{tail + 4})", "석면분담금", f"{labor_base} ×", COMMON, 1),
_r(f"{tail + 5})", "임금채권부담금", f"{labor_base} ×", COMMON, 1),
_r(
"3.",
"일반관리비",
"(직접공사비+간접노무비) ×"
if sugong
else "(직접공사비+간접공사비+관리품목자재) × 율",
RATE_RA,
0,
),
_r("4.", "이 윤", "", PENDING_PROFIT, 0),
_r("5.", "공급가액", "", TOTAL, 0),
_r("6.", "도급공사비", "", TOTAL, 0),
_r("7.", "총공사비", "", TOTAL, 0),
]
return rows
def form_rows(form: str) -> list[dict[str, Any]]:
"""형식별 서식 줄(금액 없음). 일반형식은 엔진이 셈하므로 여기 없음."""
if form == "sugong":
return _sugong()
if form == "actual_general":
return _actual(sugong=False)
if form == "actual_sugong":
return _actual(sugong=True)
raise ValueError(f"서식이 없는 형식입니다: {form}")
@@ -30,6 +30,7 @@ from B09_Estimation.B09_Estimation_CostSheet import (
sheet_rows, sheet_rows,
status_line, status_line,
) )
from B09_Estimation.B09_Estimation_CostSheet_Forms import FORM_LABELS, form_rows
from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides, clean_overrides from B09_Estimation.B09_Estimation_RateOverride import apply_overrides, clean_overrides
from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset
@@ -69,8 +70,12 @@ def _waste(bill: dict[str, Any], quantity: dict[str, Any]) -> tuple[Decimal, boo
@router.get("/{project_id}/estimation/cost-sheet") @router.get("/{project_id}/estimation/cost-sheet")
async def get_cost_sheet(project_id: UUID) -> JSONResponse: async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
"""원가계산서(일반형식) 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.""" """원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
`form` — 일반형식만 금액을 냄. 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은
막고 사유 · 0 원으로 안 채움).
"""
from B09_Estimation.B09_Estimation_Router import get_bill from B09_Estimation.B09_Estimation_Router import get_bill
from common_util.common_util_project_settings import estimation_settings, quantity_settings from common_util.common_util_project_settings import estimation_settings, quantity_settings
@@ -125,6 +130,9 @@ async def get_cost_sheet(project_id: UUID) -> JSONResponse:
"rate_version": result.rate_version, "rate_version": result.rate_version,
"notes": result.notes, "notes": result.notes,
"rate_override_count": len(overrides), "rate_override_count": len(overrides),
"form_key": "general",
"form_labels": FORM_LABELS,
**(_other_form(form) if form != "general" else {}),
} }
) )
@@ -156,6 +164,22 @@ async def put_cost_sheet(project_id: UUID, body: dict[str, Any]) -> JSONResponse
OVERRIDES_KEY = "rate_overrides" OVERRIDES_KEY = "rate_overrides"
def _other_form(form: str) -> dict[str, Any]:
"""나머지 세 형식 — 금액 없는 서식 줄로 갈아 끼움(기준 입력판 자료는 그대로)."""
if form not in FORM_LABELS:
return {"form_error": f"모르는 형식입니다: {form}"}
return {
"form_key": form,
"form": FORM_LABELS[form],
"rows": form_rows(form),
"status_line": {},
"form_note": (
"금액 미산출 — 이 형식의 요율표(수공 세부 경비·표준시장단가 제비율)가 없어"
" 서식 차례만 보임"
),
}
@router.get("/{project_id}/estimation/rate-table") @router.get("/{project_id}/estimation/rate-table")
async def get_rate_table(project_id: UUID) -> JSONResponse: async def get_rate_table(project_id: UUID) -> JSONResponse:
"""제비율 요율표 — 마스터 요율에 **이 프로젝트의 덮어쓰기**를 얹어 DFM 첫 탭 차례로. """제비율 요율표 — 마스터 요율에 **이 프로젝트의 덮어쓰기**를 얹어 DFM 첫 탭 차례로.
@@ -25,7 +25,9 @@ interface SheetRow {
level: number; level: number;
formula: string; formula: string;
rate_percent: string | null; rate_percent: string | null;
amount_krw: string; /** 형식 서식만 선 줄(수공·실적)은 null — 0 원으로 안 채움. */
amount_krw: string | null;
blocked?: boolean;
note: string; note: string;
total: boolean; total: boolean;
} }
@@ -51,6 +53,9 @@ interface CostSheetDto {
hints: Record<string, string>; hints: Record<string, string>;
}; };
waste_note: string; waste_note: string;
form_key: string;
form_labels: Record<string, string>;
form_note?: string;
bill_missing_count: number; bill_missing_count: number;
bill_unconfirmed_count: number; bill_unconfirmed_count: number;
rate_version: { dataset_id: string; effective_date: string }; rate_version: { dataset_id: string; effective_date: string };
@@ -67,7 +72,9 @@ const FORMS = [
"고용개선지원(서울)", "고용개선지원(서울)",
]; ];
/** DFM 본문 아래 탭 이름 그대로. */ /** DFM 본문 아래 탭 이름 그대로. */
const FORM_TABS = ["일반형식", "수공형식", "실적일반", "실적수공"]; const FORM_TABS = ["general", "sugong", "actual_general", "actual_sugong"];
/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */
const formOf = new Map<string, string>();
const STYLE_ID = "b09-cost-sheet-styles"; const STYLE_ID = "b09-cost-sheet-styles";
function injectStyles(): void { function injectStyles(): void {
@@ -121,9 +128,9 @@ function el<K extends keyof HTMLElementTagNameMap>(
/** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */ /** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */
const drafts = new Map<string, Record<string, string>>(); const drafts = new Map<string, Record<string, string>>();
async function fetchSheet(projectId: string): Promise<CostSheetDto> { async function fetchSheet(projectId: string, form: string): Promise<CostSheetDto> {
const response = await fetch( const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`, `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet?form=${encodeURIComponent(form)}`,
{ credentials: "include" }, { credentials: "include" },
); );
const body = (await response.json()) as CostSheetDto; const body = (await response.json()) as CostSheetDto;
@@ -278,11 +285,11 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
ctx.panel.append(box); ctx.panel.append(box);
} }
function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void { function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): void {
const wrap = el("div", "b09cs"); const wrap = el("div", "b09cs");
const head = el("div", "b09cs__head"); const head = el("div", "b09cs__head");
head.append( head.append(
el("span", "b09cs__title", "제잡비 계산 — 일반형식"), el("span", "b09cs__title", `제잡비 계산 — ${sheet.form_labels[sheet.form_key] ?? ""}`),
el( el(
"span", "span",
"b09cs__meta", "b09cs__meta",
@@ -301,6 +308,7 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void {
} }
if (sheet.bill_unconfirmed_count) head.append(unconfirmedBadge(sheet.bill_unconfirmed_count)); if (sheet.bill_unconfirmed_count) head.append(unconfirmedBadge(sheet.bill_unconfirmed_count));
if (sheet.waste_note) head.append(el("span", "b09cs__meta", `폐기물처리비: ${sheet.waste_note}`)); if (sheet.waste_note) head.append(el("span", "b09cs__meta", `폐기물처리비: ${sheet.waste_note}`));
if (sheet.form_note) head.append(el("span", "b09cs__warn", `${sheet.form_note}`));
wrap.append(head); wrap.append(head);
const body = el("div", "b09cs__sheet"); const body = el("div", "b09cs__sheet");
@@ -312,19 +320,27 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void {
label.append(el("span", "", row.label)); label.append(el("span", "", row.label));
if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`)); if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`));
label.title = [row.formula, row.note].filter(Boolean).join(" · "); label.title = [row.formula, row.note].filter(Boolean).join(" · ");
line.append(label, el("span", "b09cs__amount", won(row.amount_krw))); // 금액 없는 줄 — 「-」 + 막힌 까닭(요율표 미확보 · 확인 대기). 0 원으로 안 보임.
if (row.amount_krw === null && row.blocked) label.append(el("span", "b09cs__warn", row.note));
line.append(
label,
el("span", "b09cs__amount", row.amount_krw === null ? "" : won(row.amount_krw)),
);
body.append(line); body.append(line);
} }
wrap.append(body); wrap.append(body);
const tabs = el("div", "b09cs__tabs"); const tabs = el("div", "b09cs__tabs");
FORM_TABS.forEach((name, i) => { for (const key of FORM_TABS) {
const tab = el("button", "b09cs__tab", name); const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key);
tab.setAttribute("aria-selected", String(i === 0)); tab.setAttribute("aria-selected", String(key === sheet.form_key));
tab.disabled = i > 0; if (key !== "general") tab.title = "서식 차례만 — 요율표 미확보로 금액 안 섬";
if (i > 0) tab.title = "준비 중 — 일반형식부터 세움"; tab.addEventListener("click", () => {
tabs.append(tab); formOf.set(ctx.projectId as string, key);
reload();
}); });
tabs.append(tab);
}
wrap.append(tabs); wrap.append(tabs);
const status = el("div", "b09cs__status"); const status = el("div", "b09cs__status");
@@ -346,11 +362,11 @@ function render(ctx: B09TabContext): void {
const load = (): void => { const load = (): void => {
ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…")); ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…"));
ctx.panel.replaceChildren(); ctx.panel.replaceChildren();
fetchSheet(ctx.projectId as string) fetchSheet(ctx.projectId as string, formOf.get(ctx.projectId as string) ?? "general")
.then((sheet) => { .then((sheet) => {
ctx.body.replaceChildren(); ctx.body.replaceChildren();
drawPanel(ctx, sheet, load); drawPanel(ctx, sheet, load);
drawBody(ctx, sheet); drawBody(ctx, sheet, load);
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
ctx.body.replaceChildren( ctx.body.replaceChildren(
@@ -0,0 +1,57 @@
"""원가계산서 나머지 세 형식 — 서식 차례만 · 0 원 안 채움 · 막힌 까닭 · 확인 대기 (2026-09-14 판정).
근거: STmate 분석 12번 §4~6 · 35번 §1·§2·§5.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_CostSheet_Forms import ( # noqa: E402
PENDING_PROFIT,
PENDING_SAFETY,
RATE_B,
RATE_RA,
form_rows,
)
@pytest.mark.parametrize("form", ["sugong", "actual_general", "actual_sugong"])
def test_금액은_하나도_안_내고_0원도_없다(form) -> None:
rows = form_rows(form)
assert rows and all(row["amount_krw"] is None for row in rows)
assert all(row["note"] for row in rows)
def test_수공은_세부_경비_여덟이_율표_미확보() -> None:
rows = form_rows("sugong")
blocked = [row["label"] for row in rows if row["note"] == RATE_B]
for name in ("수도광열비", "복리후생비", "지급수수료", "폐기물처리비"):
assert name in blocked
assert "기타경비" not in [row["label"] for row in rows]
def test_실적형은_이윤이_확인_대기_실적수공은_안전관리비도() -> None:
general = {row["label"]: row["note"] for row in form_rows("actual_general")}
sugong = {row["label"]: row["note"] for row in form_rows("actual_sugong")}
assert general["이 윤"] == PENDING_PROFIT and sugong["이 윤"] == PENDING_PROFIT
assert sugong["안전관리비"] == PENDING_SAFETY
assert general["간접노무비"] == RATE_RA and general["기타경비"] == RATE_RA
def test_실적일반_보증_밑수는_직접공사비_환경은_재료직노산출경비() -> None:
"""35번 §1 — 12번의 「TA 환경보전비 = 직접공사비」는 06번 정정에 따라 재+직노+산출경비."""
rows = {row["label"]: row["formula"] for row in form_rows("actual_general")}
assert rows["하도대금 수수료"].startswith("직접공사비")
assert rows["환 경 보 전 비"].startswith("(재료비+직노+산출경비)")
def test_모르는_형식은_멈춘다() -> None:
with pytest.raises(ValueError):
form_rows("general")