From a7a1b246b61b1709efb5295f059a39b384ef1464 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 07:01:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=A4=80=EA=B3=B5=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=E2=80=94=20=EA=B3=84=EC=95=BD=EA=B8=88=EC=95=A1=20?= =?UTF-8?q?|=20=EC=A4=80=EA=B3=B5=EA=B8=88=EC=95=A1(=EA=B8=B0=EC=84=B1=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=ED=9A=8C=EC=B0=A8=20=EB=88=84?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EC=98=AE=EA=B8=B0=EA=B8=B0=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 준공 별도 계산 규칙 미확인(35번 §3) — 규칙을 짓지 않고 기성 누계를 옮김 · 기성 한 장 불변 - 기성 회차가 없으면 준공금액 비움(0 원으로 안 채움) - 기성 계산 조각(progress_for)을 떼어 준공이 같은 길로 받음 - 기성 간접재료비 확인 대기 닫음 — 예정가격작성기준 제17조·제39조② 근거(브레인 판정) - 준공 탭 파일 · 사전 키(등록은 서브) · 시험 2건 · 전체 1730 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_Progress.py | 46 ++++- .../B09_Estimation_Router_Progress.py | 37 +++- .../B09_Estimation_UI_Tab_Completion.ts | 178 ++++++++++++++++++ resources/tester/test_b09_progress.py | 25 ++- ui_template/ui_template_locale_b4.ts | 1 + 5 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_Completion.ts diff --git a/B09_Estimation/B09_Estimation_Progress.py b/B09_Estimation/B09_Estimation_Progress.py index e0ee08d5..d6c8ad55 100644 --- a/B09_Estimation/B09_Estimation_Progress.py +++ b/B09_Estimation/B09_Estimation_Progress.py @@ -17,7 +17,8 @@ ③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖 원문 대조: 예정가격작성기준 제39조②(표준시장단가 장) 간접공사비 「1. 간접노무비」~「10.」 — 간접재료비 없음 · 제17조(원가계산 장)는 간접재료비를 재료비 안에 둠. 원가계산 장에는 - 「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님. + 「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님 — 간접재료비 자리는 두 조문이 + 한 방향이라 ③ 확인 대기 닫음(2026-09-14 브레인 판정). ①′ 계약잡비율 직접입력(사유 필수)이 역산값을 이김 — 계약서에 제잡비율이 명시됨(브레인 판정). ④ 사정 — 「기성내역서(사정)」 열 이름뿐이라 칸만 받고 계산에 안 씀. """ @@ -403,7 +404,7 @@ def progress_sheet( if cost_data.indirect_material_krw: notes.append( f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖" - "(원자료 「간접노무비부터 부가세 직전」) — 확인 대기" + "(예정가격작성기준 제17조 재료비 안 · 제39조② 간접공사비 목록 밖)" ) unknown = sorted(set(overrides) - set(ratios)) if unknown: @@ -419,3 +420,44 @@ def progress_sheet( "vat": rounds[current - 1] if current else None, "notes": notes, } + + +def completion_sheet(progress: dict[str, Any]) -> dict[str, Any]: + """준공 — 「계약금액 | 준공금액」 두 열(27번 §5 · 35번 §3 `준공조서(을/병)`). + + ⚠ 준공 별도 계산 규칙은 **미확인** — 지어내지 않고 기성 **마지막 회차 누계**를 옮기기만 함. + 기성 한 장(`progress_sheet`, 마지막 회차)을 읽기만 하고 고치지 않음. 회차가 없으면 옮길 + 누계가 없어 준공금액을 비워 둠(0 원으로 안 채움). + """ + has_rounds = bool(progress.get("round")) + + def moved(value: Any) -> str | None: + return value if has_rounds and value is not None else None + + rows = [ + { + **{key: row.get(key) for key in ("item_no", "name", "spec", "unit", "is_group")}, + "contract_amount_krw": row.get("contract_amount_krw"), + "completion_amount_krw": moved(row.get("progress_cumulative_amount_krw")), + } + for row in progress["rows"] + if row.get("in_bill", True) + ] + lines = [ + { + "key": line["key"], + "name": line["name"], + "total": total, + "contract_krw": line["contract_krw"], + "completion_krw": moved(line.get("cumulative_krw")), + "completion_pct": line.get("cumulative_pct") if has_rounds else None, + } + for lines_of, total in ((progress["items"], False), (progress["summary"], True)) + for line in lines_of + ] + return { + "rows": rows, + "lines": lines, + "from_round": progress.get("round") or 0, + "notes": [] if has_rounds else ["기성 회차 없음 — 옮길 누계가 없어 준공금액 비움"], + } diff --git a/B09_Estimation/B09_Estimation_Router_Progress.py b/B09_Estimation/B09_Estimation_Router_Progress.py index feb8ab8f..ac5328f1 100644 --- a/B09_Estimation/B09_Estimation_Router_Progress.py +++ b/B09_Estimation/B09_Estimation_Router_Progress.py @@ -19,6 +19,7 @@ from B09_Estimation.B09_Estimation_Progress import ( SETTINGS_KEY, VAT_CHOICES, clean_settings, + completion_sheet, progress_sheet, ) from B09_Estimation.B09_Estimation_Rates import RateLookupError @@ -34,9 +35,10 @@ def _cut_options(first: str) -> list[dict[str, str]]: ] -@router.get("/{project_id}/estimation/progress") -async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse: - """기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차.""" +async def progress_for( + project_id: UUID, round: int | None +) -> tuple[dict[str, Any], dict[str, Any]] | JSONResponse: + """(기성 저장값, 기성 한 장) — 못 서면 그 까닭 응답. 준공도 이 길로 기성본을 받음.""" from B09_Estimation.B09_Estimation_Router_Contract import contract_for from B09_Estimation.B09_Estimation_Router_CostSheet import cost_from_bill from common_util.common_util_project_settings import estimation_settings @@ -52,7 +54,34 @@ async def get_progress(project_id: UUID, round: int | None = None) -> JSONRespon except RateLookupError as error: return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {})) - sheet = progress_sheet(contract["rows"], cost_data, cost_result, stored, round) + return stored, progress_sheet(contract["rows"], cost_data, cost_result, stored, round) + + +@router.get("/{project_id}/estimation/completion") +async def get_completion(project_id: UUID) -> JSONResponse: + """준공 한 장 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮김).""" + found = await progress_for(project_id, None) + if isinstance(found, JSONResponse): + return found + return JSONResponse( + content={ + "status": "success", + **completion_sheet(found[1]), + "limit_note": ( + "준공 별도 계산 규칙 미확인(35번 §3) — 기성 마지막 회차 누계를 옮기기만 함 · " + "준공 표본 0건" + ), + } + ) + + +@router.get("/{project_id}/estimation/progress") +async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse: + """기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차.""" + found = await progress_for(project_id, round) + if isinstance(found, JSONResponse): + return found + stored, sheet = found return JSONResponse( content={ "status": "success", diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts b/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts new file mode 100644 index 00000000..3fe2050f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Completion.ts + * 준공 탭 — STmate 「준공조서(을/병)」의 「계약금액 | 준공금액」 두 열을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 위 = 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). + * - 아래 = 공종별 계약금액 | 준공금액. + * - ⚠ 준공 별도 계산 규칙은 미확인 — 서버(`/estimation/completion`)가 기성 마지막 회차 누계를 + * 옮기기만 함. 입력 칸·[저장] 없음(기성 탭에서 고침). + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +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 CompletionRow { + item_no: string; + name: string; + spec: string | null; + unit: string | null; + is_group: boolean; + contract_amount_krw: string | null; + completion_amount_krw: string | null; +} + +interface CompletionLine { + key: string; + name: string; + total: boolean; + contract_krw: string; + completion_krw: string | null; + completion_pct: string | null; +} + +interface CompletionDto { + status: string; + message?: string; + rows: CompletionRow[]; + lines: CompletionLine[]; + from_round: number; + notes: string[]; + limit_note: string; +} + +const STYLE_ID = "b09-completion-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09cp { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09cp__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09cp__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09cp__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09cp__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09cp__table th, .b09cp__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09cp__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09cp__table tr.is-total td, .b09cp__table tr.is-group td { font-weight: 600; } +`; + document.head.append(style); +} + +function el( + 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; +} + +async function fetchCompletion(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/completion`, + { credentials: "include" }, + ); + const body = (await response.json()) as CompletionDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +function table(headers: string[]): HTMLTableElement { + const node = el("table", "b09cp__table"); + const head = el("tr"); + for (const label of headers) head.append(el("th", "", label)); + node.append(head); + return node; +} + +function draw(ctx: B09TabContext, data: CompletionDto): void { + const wrap = el("div", "b09cp"); + wrap.append(el("div", "b09cp__warn", `⚠ ${data.limit_note}`)); + for (const note of data.notes) wrap.append(el("div", "b09cp__warn", `⚠ ${note}`)); + wrap.append( + el( + "div", + "b09cp__meta", + data.from_round + ? `준공금액 = 기성 ${data.from_round}회(마지막 회차) 누계 — 고칠 곳은 기성 탭` + : "기성 탭에서 회차를 넣으면 그 누계가 준공금액으로 옮겨짐", + ), + ); + const scroll = el("div", "b09cp__scroll"); + + const summary = table(["명칭", "계약금액", "준공금액", "준공(%)"]); + for (const line of data.lines) { + const tr = el("tr", line.total ? "is-total" : ""); + tr.append( + el("td", "", line.name), + el("td", "num", won(line.contract_krw)), + el("td", "num", won(line.completion_krw)), + el("td", "num", line.completion_pct ? `${line.completion_pct}%` : ""), + ); + summary.append(tr); + } + const bill = table(["공종번호", "명칭", "규격", "단위", "계약금액", "준공금액"]); + for (const row of data.rows) { + const tr = el("tr", row.is_group ? "is-group" : ""); + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", won(row.contract_amount_krw)), + el("td", "num", won(row.completion_amount_krw)), + ); + bill.append(tr); + } + const top = el("div"); + top.append(el("strong", "", "준공조서 — 계약금액 | 준공금액"), summary); + const bottom = el("div"); + bottom.append(el("strong", "", "공종별"), bill); + scroll.append(top, bottom); + wrap.append(scroll); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09cp__meta", "프로젝트를 고르세요")); + return; + } + ctx.body.replaceChildren( + el("div", "b09cp__meta", `${L("B09_Estimation_Tab_Completion")} 계산 중…`), + ); + fetchCompletion(ctx.projectId) + .then((data) => { + ctx.body.replaceChildren(); + draw(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09cp__warn", + `준공을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); +} + +export const completionTab: B09Tab = { + key: "completion", + label: () => L("B09_Estimation_Tab_Completion"), + render, +}; diff --git a/resources/tester/test_b09_progress.py b/resources/tester/test_b09_progress.py index 4003a365..3d60eea0 100644 --- a/resources/tester/test_b09_progress.py +++ b/resources/tester/test_b09_progress.py @@ -20,7 +20,11 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402 -from B09_Estimation.B09_Estimation_Progress import clean_settings, progress_sheet # noqa: E402 +from B09_Estimation.B09_Estimation_Progress import ( # noqa: E402 + clean_settings, + completion_sheet, + progress_sheet, +) def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict: @@ -201,6 +205,25 @@ def test_이윤금액_직접입력과_사정_칸() -> None: assert row["progress_current_amount_krw"] == "140000" +def test_준공은_마지막_회차_누계를_옮기기만() -> None: + progress = _sheet() + before = copy.deepcopy(progress) + done = completion_sheet(progress) + assert progress == before # 기성 한 장 불변 + row = {r["item_no"]: r for r in done["rows"]}["1.1"] + assert (row["contract_amount_krw"], row["completion_amount_krw"]) == ("350000", "245000") + lines = {line["key"]: line for line in done["lines"]} + summary = {s["key"]: s for s in progress["summary"]} + assert lines["total"]["completion_krw"] == summary["total"]["cumulative_krw"] + assert lines["indirect_labor_cost"]["completion_krw"] == "12750" and done["from_round"] == 2 + + +def test_기성_회차가_없으면_준공금액을_비운다() -> None: + done = completion_sheet(_sheet({"rounds": []})) + assert all(line["completion_krw"] is None for line in done["lines"]) + assert done["notes"] and done["from_round"] == 0 + + def test_틀린_칸은_거른다() -> None: _, errors = clean_settings( { diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 95ca65d0..641f344c 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -11,4 +11,5 @@ export const ui_locales_b4 = { B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"], B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"], B09_Estimation_Tab_Progress: ["기성", "Progress Payment"], + B09_Estimation_Tab_Completion: ["준공", "Completion"], } as const;