From 6683e85d948cc281a16cb70a29475858238533ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 04:18:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=EB=82=98=EB=A8=B8=EC=A7=80=20=EC=84=B8=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=EC=84=9C=EC=8B=9D=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?=E2=80=94=20=EC=88=98=EA=B3=B5=C2=B7=EC=8B=A4=EC=A0=81=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=C2=B7=EC=8B=A4=EC=A0=81=EC=88=98=EA=B3=B5=20=C2=B7=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EC=97=86=EC=9D=B4=20=EB=A7=89=ED=9E=8C=20?= =?UTF-8?q?=EA=B9=8C=EB=8B=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 12번 §4~6 서식(35번 정정 반영) 차례·표기 · 줄마다 「공통 요율 / 요율표 미확보(RateB·RATE_RA·An_A) / 확인 대기」 - 확인 대기: 실적형 이윤 밑수(35번 §5-1) · 실적수공 안전관리비 존재(§5-2) — 값을 지어내지 않음 - 0 원으로 안 채움 — 금액 칸 「-」 + 까닭 · 머리에 「금액 미산출」 - 원가계산서 아래 형식 탭 넷을 눌러 바꿈(GET ?form=) · 기준 입력판은 그대로 - 시험: 금액·0원 없음 · 수공 경비 여덟 막힘 · 확인 대기 · 실적일반 보증·환경 밑수 · 모르는 형식 멈춤 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B09_Estimation_CostSheet_Forms.py | 164 ++++++++++++++++++ .../B09_Estimation_Router_CostSheet.py | 28 ++- .../B09_Estimation_UI_Tab_CostSheet.ts | 46 +++-- resources/tester/test_b09_cost_sheet_forms.py | 57 ++++++ 4 files changed, 278 insertions(+), 17 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_CostSheet_Forms.py create mode 100644 resources/tester/test_b09_cost_sheet_forms.py diff --git a/B09_Estimation/B09_Estimation_CostSheet_Forms.py b/B09_Estimation/B09_Estimation_CostSheet_Forms.py new file mode 100644 index 00000000..fbb770a3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_CostSheet_Forms.py @@ -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}") diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py index 2871f66d..bc985ff5 100644 --- a/B09_Estimation/B09_Estimation_Router_CostSheet.py +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -30,6 +30,7 @@ from B09_Estimation.B09_Estimation_CostSheet import ( sheet_rows, 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_RateOverride import apply_overrides, clean_overrides 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") -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 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, "notes": result.notes, "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" +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") async def get_rate_table(project_id: UUID) -> JSONResponse: """제비율 요율표 — 마스터 요율에 **이 프로젝트의 덮어쓰기**를 얹어 DFM 첫 탭 차례로. diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts index bb5f925d..03675497 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -25,7 +25,9 @@ interface SheetRow { level: number; formula: string; rate_percent: string | null; - amount_krw: string; + /** 형식 서식만 선 줄(수공·실적)은 null — 0 원으로 안 채움. */ + amount_krw: string | null; + blocked?: boolean; note: string; total: boolean; } @@ -51,6 +53,9 @@ interface CostSheetDto { hints: Record; }; waste_note: string; + form_key: string; + form_labels: Record; + form_note?: string; bill_missing_count: number; bill_unconfirmed_count: number; rate_version: { dataset_id: string; effective_date: string }; @@ -67,7 +72,9 @@ const FORMS = [ "고용개선지원(서울)", ]; /** DFM 본문 아래 탭 이름 그대로. */ -const FORM_TABS = ["일반형식", "수공형식", "실적일반", "실적수공"]; +const FORM_TABS = ["general", "sugong", "actual_general", "actual_sugong"]; +/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */ +const formOf = new Map(); const STYLE_ID = "b09-cost-sheet-styles"; function injectStyles(): void { @@ -121,9 +128,9 @@ function el( /** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */ const drafts = new Map>(); -async function fetchSheet(projectId: string): Promise { +async function fetchSheet(projectId: string, form: string): Promise { 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" }, ); const body = (await response.json()) as CostSheetDto; @@ -278,11 +285,11 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): 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 head = el("div", "b09cs__head"); head.append( - el("span", "b09cs__title", "제잡비 계산 — 일반형식"), + el("span", "b09cs__title", `제잡비 계산 — ${sheet.form_labels[sheet.form_key] ?? ""}`), el( "span", "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.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); const body = el("div", "b09cs__sheet"); @@ -312,19 +320,27 @@ function drawBody(ctx: B09TabContext, sheet: CostSheetDto): void { label.append(el("span", "", row.label)); if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`)); 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); } wrap.append(body); const tabs = el("div", "b09cs__tabs"); - FORM_TABS.forEach((name, i) => { - const tab = el("button", "b09cs__tab", name); - tab.setAttribute("aria-selected", String(i === 0)); - tab.disabled = i > 0; - if (i > 0) tab.title = "준비 중 — 일반형식부터 세움"; + for (const key of FORM_TABS) { + const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key); + tab.setAttribute("aria-selected", String(key === sheet.form_key)); + if (key !== "general") tab.title = "서식 차례만 — 요율표 미확보로 금액 안 섬"; + tab.addEventListener("click", () => { + formOf.set(ctx.projectId as string, key); + reload(); + }); tabs.append(tab); - }); + } wrap.append(tabs); const status = el("div", "b09cs__status"); @@ -346,11 +362,11 @@ function render(ctx: B09TabContext): void { const load = (): void => { ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…")); ctx.panel.replaceChildren(); - fetchSheet(ctx.projectId as string) + fetchSheet(ctx.projectId as string, formOf.get(ctx.projectId as string) ?? "general") .then((sheet) => { ctx.body.replaceChildren(); drawPanel(ctx, sheet, load); - drawBody(ctx, sheet); + drawBody(ctx, sheet, load); }) .catch((error: unknown) => { ctx.body.replaceChildren( diff --git a/resources/tester/test_b09_cost_sheet_forms.py b/resources/tester/test_b09_cost_sheet_forms.py new file mode 100644 index 00000000..2919a7f4 --- /dev/null +++ b/resources/tester/test_b09_cost_sheet_forms.py @@ -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")