Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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,
|
||||
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 첫 탭 차례로.
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
waste_note: string;
|
||||
form_key: string;
|
||||
form_labels: Record<string, string>;
|
||||
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<string, string>();
|
||||
|
||||
const STYLE_ID = "b09-cost-sheet-styles";
|
||||
function injectStyles(): void {
|
||||
@@ -121,9 +128,9 @@ function el<K extends keyof HTMLElementTagNameMap>(
|
||||
/** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */
|
||||
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(
|
||||
`${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(
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user