From 1edde503fe61a994131e801bb8b7d43d11998106 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 06:03:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EA=B8=B0=EC=84=B1=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=201=EB=B2=8C=20=E2=80=94=20=EA=B8=88=ED=9A=8C?= =?UTF-8?q?=EC=A7=81=EC=A0=91=EA=B3=B5=EC=82=AC=EB=B9=84=20=C3=97=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9E=A1=EB=B9=84=EC=9C=A8=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD/=EC=A0=84=ED=9A=8C/=EA=B8=88=ED=9A=8C/?= =?UTF-8?q?=EB=88=84=EA=B3=84=EC=99=80=20=EA=B0=81=20=EC=9C=A8=20=C2=B7=20?= =?UTF-8?q?=EB=B6=80=EA=B0=80=EC=84=B8=20=EB=84=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계약내역·계약 원가계산서(같은 엔진에 계약 직접비)를 읽기만 해 파생 — 계약 불변 - 제잡비 줄마다 계약잡비율 = 도급액 ÷ 계약 직접공사비 (구조로 읽음, 확인 대기) - 회차 목록 저장 · 기성(%) · 잔량 · 계약 수량 넘으면 알림 - 부가세: 직접입력 / 공급가액 / 재료비 / 재료비+산출경비 (설계 vat_base 같이 씀) - 원가계산서 조립·계약본 받기 조각을 떼어 기성이 같은 길로 받음 - 기성 탭 파일 · 사전 키(등록은 서브) · 시험 6건 · 전체 1717 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_Progress.py | 286 ++++++++++++++ .../B09_Estimation_Router_Contract.py | 21 +- .../B09_Estimation_Router_CostSheet.py | 20 +- .../B09_Estimation_Router_Progress.py | 88 +++++ .../B09_Estimation_UI_Tab_Progress.ts | 370 ++++++++++++++++++ main.py | 2 + resources/tester/test_b09_progress.py | 158 ++++++++ ui_template/ui_template_locale_b4.ts | 1 + 8 files changed, 938 insertions(+), 8 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Progress.py create mode 100644 B09_Estimation/B09_Estimation_Router_Progress.py create mode 100644 B09_Estimation/B09_Estimation_UI_Tab_Progress.ts create mode 100644 resources/tester/test_b09_progress.py diff --git a/B09_Estimation/B09_Estimation_Progress.py b/B09_Estimation/B09_Estimation_Progress.py new file mode 100644 index 00000000..a002d90d --- /dev/null +++ b/B09_Estimation/B09_Estimation_Progress.py @@ -0,0 +1,286 @@ +"""B09 기성 단계 — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 2026-09-14 배정). + +근거: STmate 분석 `27_계약_실행_기성_단계.md` §3 (`wM_KanJub_K` 「기성 제잡비 계산서」) · + `22_UI_폼_분석.md` §3.5 · `35_형식과_단계의_공통과_차이.md` §3 · + 원자료 `ui_form_catalog.txt` 133~137행. + · ⭐ 간접비를 설계처럼 줄마다 밑수 × 요율로 다시 셈하지 **않음** — + 「금회직접공사비 × 계약제잡비율」 + · 계약(도급액) · 전회 · 금회 · 누계 네 칸과 각 비율(전율·금율·누율) + · 부가세 (0) 직접입력 · (1) 공급가액의 10% · (2) 재료비의 10% · (3) 재료비+산출경비의 10% + +⚠ **계약을 안 건드린다** — 계약내역 줄(`contract_bill` 결과)과 계약 원가계산서(같은 엔진에 계약 + 직접비)를 **읽기만** 해서 파생한다. +⚠ **값이 맞다가 아니라 구조가 선다까지** — 기성 표본 0건(27번 §9 · 35번 「회차별 값과 + `QTY1_B/N/O/H` 뜻 미확인」). 그 네 칸은 쓰지 않고, 아래는 **구조로 읽은 것**이라 확인 대기: + ① 계약잡비율 = 계약 원가계산서 그 줄 금액 ÷ 계약 직접공사비(격자 `계약잡비율` 열이 줄마다 있음) + ② 회차마다 금회 금액을 원 미만 절사해 쌓고 전회 = 앞 회차 금회의 합 · 누계 = 전회 + 금회 + ③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖 +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line +from B09_Estimation.B09_Estimation_Contract import _group_sums, _money +from B09_Estimation.B09_Estimation_CostSheet import EXPENSE_ORDER +from B09_Estimation.B09_Estimation_Engine_Cost_Options import vat_base +from B09_Estimation.B09_Estimation_PriceBook import Money3 + +_ZERO = Decimal(0) +_HUNDRED = Decimal(100) + +#: 저장 자리 — `estimation` 구획 안 한 칸. +SETTINGS_KEY = "progress" + +#: 부가세 — `cmBx_Buga` 표기 그대로. 키는 설계 원가계산서 `VAT_MODES` 와 같은 뜻이면 같은 키. +VAT_CHOICES: tuple[tuple[str, str], ...] = ( + ("manual", "(0) 직접입력"), + ("supply", "(1) 공급가액의 10%"), + ("material", "(2) 재료비의 10%"), + ("forest_coop", "(3) 재료비+산출경비의 10%"), +) +#: 화면 표기 「10%」 그대로. +_VAT_PERCENT = Decimal(10) + +#: 제잡비 줄 — 간접노무비 ~ 부가세 직전. 설계 원가계산서 서식 차례. +_ITEM_ORDER: tuple[tuple[str, str], ...] = ( + ("indirect_labor_cost", "간접노무비"), + *EXPENSE_ORDER, + ("general_overhead", "일반관리비"), + ("profit", "이윤"), +) + +_COLUMNS = ("previous", "current", "cumulative") + + +def _number(value: Any) -> Decimal | None: + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + return number if number.is_finite() and number >= 0 else None + + +def _floor(value: Decimal) -> Decimal: + return value.to_integral_value(rounding=ROUND_FLOOR) + + +def _pct(part: Decimal, whole: Decimal) -> str | None: + return str((part / whole * _HUNDRED).quantize(Decimal("0.001"))) if whole else None + + +def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """회차 목록 저장값과 거른 까닭 — 회차마다 금회 기성수량 · 부가세 방식(직접입력이면 금액).""" + errors: list[str] = [] + rounds: list[dict[str, Any]] = [] + for number, entry in enumerate(values.get("rounds") or [], start=1): + entry = entry or {} + quantities: dict[str, str] = {} + for item_no, raw in (entry.get("quantities") or {}).items(): + if raw in (None, ""): + continue + qty = _number(raw) + if qty is None: + errors.append(f"{number}회 {item_no} 기성수량이 0 이상 수가 아님 — {raw}") + continue + quantities[str(item_no)] = str(qty) + mode = str(entry.get("vat_mode") or "supply") + if mode not in dict(VAT_CHOICES): + errors.append(f"{number}회 부가세 방식을 모름 — {mode}") + mode = "supply" + manual = entry.get("vat_manual_krw") + if mode == "manual" and _number(manual) is None: + errors.append(f"{number}회 부가세 직접입력 금액이 0 이상 수가 아님 — {manual}") + rounds.append( + { + "quantities": quantities, + "vat_mode": mode, + "vat_manual_krw": str(_number(manual)) if _number(manual) is not None else "", + } + ) + return {"rounds": rounds}, errors + + +def _leaf(row: dict[str, Any]) -> bool: + return ( + not row.get("is_group") + and row.get("in_bill", True) + and row.get("contract_amount_krw") is not None + ) + + +def _unit(row: dict[str, Any]) -> Money3: + return Money3( + material=_money(row.get("contract_unit_material_krw")), + labor=_money(row.get("contract_unit_labor_krw")), + expense=_money(row.get("contract_unit_expense_krw")), + ) + + +def cost_items(cost_data: Any, cost_result: Any) -> list[tuple[str, str, Decimal]]: + """계약 원가계산서의 제잡비 줄 (키, 이름, 도급액) — 분리발주 폐기물은 도급 밖이라 뺌.""" + items = [] + for key, name in _ITEM_ORDER: + if not cost_result.has(key): + continue + if key == "waste_disposal" and cost_data.waste_separate_order: + continue + items.append((key, name, cost_result.amount(key))) + return items + + +def _round_money( + rows: list[dict[str, Any]], + entry: dict[str, Any], + items: list[tuple[str, str, Decimal]], + contract_direct: Decimal, +) -> dict[str, Any]: + """한 회차의 금회 — 줄 금액 · 직접공사비 · 제잡비 줄 · 공급가액 · 부가세.""" + lines: dict[str, Money3] = {} + direct = Money3() + for row in rows: + qty = entry["quantities"].get(str(row.get("item_no"))) + if qty is None or not _leaf(row): + continue + lines[str(row["item_no"])] = bill_line(_unit(row), Decimal(qty)) + direct += lines[str(row["item_no"])] + item_amounts = { + key: _floor(direct.total * amount / contract_direct) if contract_direct else _ZERO + for key, _, amount in items + } + supply = direct.total + sum(item_amounts.values(), _ZERO) + if entry["vat_mode"] == "manual": + vat = Decimal(entry["vat_manual_krw"] or 0) + else: + base = vat_base(entry["vat_mode"], supply, direct.material, direct.expense, _ZERO) + vat = _floor(base * _VAT_PERCENT / _HUNDRED) + return { + "lines": lines, + "direct": direct, + "items": item_amounts, + "supply": supply, + "vat": vat, + } + + +def progress_sheet( + contract_rows: list[dict[str, Any]], + cost_data: Any, + cost_result: Any, + settings: dict[str, Any], + round_no: int | None = None, +) -> dict[str, Any]: + """기성내역 · 기성 제잡비 계산서 한 장 — `round_no`(1부터) 회차를 금회로. 없으면 마지막 회차.""" + rounds = settings.get("rounds") or [] + current = len(rounds) if round_no is None else max(0, min(round_no, len(rounds))) + contract_direct = sum( + (_money(r["contract_amount_krw"]) for r in contract_rows if _leaf(r)), _ZERO + ) + items = cost_items(cost_data, cost_result) + per_round = [_round_money(contract_rows, entry, items, contract_direct) for entry in rounds] + empty = {"lines": {}, "direct": Money3(), "items": {}, "supply": _ZERO, "vat": _ZERO} + before = per_round[: max(current - 1, 0)] + now = per_round[current - 1] if current else empty + + def four(pick) -> dict[str, Decimal]: + """전회(앞 회차 금회의 합) · 금회 · 누계(전회 + 금회).""" + previous = sum((pick(r) for r in before), _ZERO) + return {"previous": previous, "current": pick(now), "cumulative": previous + pick(now)} + + rows: list[dict[str, Any]] = [] + for source in contract_rows: + row = dict(source) + rows.append(row) + if not _leaf(row): + continue + item_no = str(row.get("item_no")) + qty_col = { + "previous": sum( + (Decimal(rounds[i]["quantities"].get(item_no, "0")) for i in range(len(before))), + _ZERO, + ), + "current": Decimal(rounds[current - 1]["quantities"].get(item_no, "0")) + if current + else _ZERO, + } + qty_col["cumulative"] = qty_col["previous"] + qty_col["current"] + money = { + "previous": sum((r["lines"].get(item_no, Money3()) for r in before), Money3()), + "current": now["lines"].get(item_no, Money3()), + } + money["cumulative"] = money["previous"] + money["current"] + contract_qty = _money(row.get("quantity")) + for col in _COLUMNS: + row.update( + { + f"progress_{col}_quantity": str(qty_col[col]), + f"progress_{col}_material_krw": str(money[col].material), + f"progress_{col}_labor_krw": str(money[col].labor), + f"progress_{col}_expense_krw": str(money[col].expense), + f"progress_{col}_amount_krw": str(money[col].total), + } + ) + row["progress_pct"] = _pct(qty_col["cumulative"], contract_qty) + row["progress_remaining_quantity"] = str(contract_qty - qty_col["cumulative"]) + row["progress_note"] = ( + "누계 기성수량이 계약 수량을 넘음" if qty_col["cumulative"] > contract_qty else "" + ) + for col in _COLUMNS: + _group_sums(rows, f"progress_{col}") + + contract_items_total = sum((amount for _, _, amount in items), _ZERO) + item_rows = [] + for key, name, amount in items: + values = four(lambda r, k=key: r["items"].get(k, _ZERO)) + item_rows.append( + { + "key": key, + "name": name, + "contract_krw": str(amount), + "contract_ratio_pct": _pct(amount, contract_direct), + **{f"{col}_krw": str(values[col]) for col in _COLUMNS}, + **{f"{col}_pct": _pct(values[col], amount) for col in _COLUMNS}, + } + ) + contract_supply = contract_direct + contract_items_total + summary = [] + for key, name, contract_amount, pick in ( + ("direct", "직접공사비", contract_direct, lambda r: r["direct"].total), + ("items", "제잡비 계", contract_items_total, lambda r: sum(r["items"].values(), _ZERO)), + ("supply", "공급가액", contract_supply, lambda r: r["supply"]), + ("vat", "부가가치세", cost_result.amount("vat"), lambda r: r["vat"]), + ( + "total", + "기성금액", + contract_supply + cost_result.amount("vat"), + lambda r: r["supply"] + r["vat"], + ), + ): + values = four(pick) + summary.append( + { + "key": key, + "name": name, + "contract_krw": str(contract_amount), + **{f"{col}_krw": str(values[col]) for col in _COLUMNS}, + **{f"{col}_pct": _pct(values[col], contract_amount) for col in _COLUMNS}, + } + ) + notes = [] + if cost_data.indirect_material_krw: + notes.append( + f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖" + "(원자료 「간접노무비부터 부가세 직전」) — 확인 대기" + ) + return { + "rows": rows, + "items": item_rows, + "summary": summary, + "contract_overhead_ratio_pct": _pct(contract_items_total, contract_direct), + "round": current, + "round_count": len(rounds), + "vat": rounds[current - 1] if current else None, + "notes": notes, + } diff --git a/B09_Estimation/B09_Estimation_Router_Contract.py b/B09_Estimation/B09_Estimation_Router_Contract.py index 6a8ece0d..210cab13 100644 --- a/B09_Estimation/B09_Estimation_Router_Contract.py +++ b/B09_Estimation/B09_Estimation_Router_Contract.py @@ -32,9 +32,13 @@ async def _root(project_id: UUID) -> str | None: return await _project_root_of(project_id) -@router.get("/{project_id}/estimation/contract") -async def get_contract(project_id: UUID) -> JSONResponse: - """계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션.""" +async def contract_for( + project_id: UUID, +) -> tuple[str, dict[str, Any], dict[str, Any], dict[str, Any]] | JSONResponse: + """(저장 폴더, 설계 내역, 계약 조건, 계약내역) — 못 서면 그 까닭 응답. + + 기성도 이 길로 계약본을 받음(계약 값을 따로 셈하지 않음). + """ from B09_Estimation.B09_Estimation_Router import _build_for, get_bill from common_util.common_util_project_settings import estimation_settings @@ -51,7 +55,16 @@ async def get_contract(project_id: UUID) -> JSONResponse: stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {})) # 조립본은 캐시 공유본 — `contract_bill` 이 단가표 복사본에만 적용률을 얹음(설계 불변). build = await _build_for(project_id) if stored.get("apply_to_base_prices") else None - result = contract_bill(bill["rows"], stored, build=build) + return root, bill, stored, contract_bill(bill["rows"], stored, build=build) + + +@router.get("/{project_id}/estimation/contract") +async def get_contract(project_id: UUID) -> JSONResponse: + """계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션.""" + found = await contract_for(project_id) + if isinstance(found, JSONResponse): + return found + _, bill, stored, result = found return JSONResponse( content={ "status": "success", diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py index bc985ff5..ac85aa7c 100644 --- a/B09_Estimation/B09_Estimation_Router_CostSheet.py +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -69,6 +69,20 @@ def _waste(bill: dict[str, Any], quantity: dict[str, Any]) -> tuple[Decimal, boo return amount, separate, f"임목폐기물 {tons:,.2f}톤 × {price:,.0f}원 — ⚠ 수동 단가(미확정)" +def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]): + """(엔진 입력, 원가계산 결과, 폐기물 사유) — 직접비만 갈아 끼우면 계약·기성도 같은 길로 섬. + + 기준 입력·요율 덮어쓰기·폐기물 톤은 프로젝트 저장값 그대로. `RateLookupError` 는 부른 쪽이 받음. + """ + from common_util.common_util_project_settings import estimation_settings, quantity_settings + + stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {}) + waste, separate, waste_note = _waste(bill, quantity_settings(root)) + overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ()) + data = cost_input(direct, stored, waste, separate, overrides) + return data, calculate_cost(data), waste_note + + @router.get("/{project_id}/estimation/cost-sheet") async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse: """원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄. @@ -77,7 +91,7 @@ async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONRespons 막고 사유 · 0 원으로 안 채움). """ 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 root = await _root(project_id) if root is None: @@ -97,12 +111,10 @@ async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONRespons for part in ("material", "labor", "expense") } stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {}) - waste, separate, waste_note = _waste(bill, quantity_settings(root)) overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ()) - data = cost_input(direct, stored, waste, separate, overrides) try: + data, result, waste_note = cost_from_bill(root, bill, direct) dataset = load_rate_dataset(data.rate_file_name) - result = calculate_cost(data) except RateLookupError as error: return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) return JSONResponse( diff --git a/B09_Estimation/B09_Estimation_Router_Progress.py b/B09_Estimation/B09_Estimation_Router_Progress.py new file mode 100644 index 00000000..ad5199dc --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Progress.py @@ -0,0 +1,88 @@ +"""B09 기성 단계 탭 API — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 랩탑 메인). + +⚠ 계약내역(`contract_for`)과 계약 원가계산서(`cost_from_bill` 에 계약 직접비)를 **읽기만** 한다. + 저장은 `estimation.progress` 한 칸(회차 목록) — 설계·계약·원가계산서 저장본은 안 건드린다. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_Progress import ( + SETTINGS_KEY, + VAT_CHOICES, + clean_settings, + progress_sheet, +) +from B09_Estimation.B09_Estimation_Rates import RateLookupError + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Progress"]) + + +@router.get("/{project_id}/estimation/progress") +async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse: + """기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차.""" + 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 + + found = await contract_for(project_id) + if isinstance(found, JSONResponse): + return found + root, bill, _, contract = found + totals = contract["totals"]["contract"] + direct = {part: Decimal(totals[f"{part}_krw"]) for part in ("material", "labor", "expense")} + try: + cost_data, cost_result, _ = cost_from_bill(root, bill, direct) + 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 JSONResponse( + content={ + "status": "success", + **sheet, + "settings": stored, + "fields": {"vat": [{"key": k, "label": label} for k, label in VAT_CHOICES]}, + "limit_note": ( + "기성 표본 0건 — 구조가 서는지까지만 확인됨 · 계약잡비율(도급액 ÷ 계약 직접공사비)·" + "회차별 절사는 구조로 읽음 · 사정·절사 선택 미구현" + ), + } + ) + + +@router.put("/{project_id}/estimation/progress") +async def put_progress(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """회차 목록 저장 — 틀린 칸이 있으면 아무것도 안 저장.""" + from B09_Estimation.B09_Estimation_Router import _project_root_of + from common_util.common_util_project_settings import save_section + + root = await _project_root_of(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}) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts b/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts new file mode 100644 index 00000000..9fc70bcb --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Progress.ts + * 기성 탭 — STmate 「기성 제잡비 계산서」(wM_KanJub_K)와 기성내역서(계약|전회|금회|누계)를 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 회차 고르기 · [회차 추가] · 부가세 방식 넷(직접입력이면 금액) · [저장]. + * - 본문 위 = 기성 제잡비 계산서 — 도급액 · 계약잡비율 · 전회/금회/누계 금액과 율. + * - 본문 아래 = 기성내역 — 계약 · 전회 · **금회 기성수량 칸** · 누계 · 기성(%) · 잔량. + * - ⚠ 값은 서버(`/estimation/progress`)가 계약내역·계약 원가계산서에서 파생 — 계약은 안 바뀜. + * - ⚠ 기성 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. + * ========================================================================== */ + +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]; +} + +type Column = "previous" | "current" | "cumulative"; +const COLUMNS: [Column, string, string][] = [ + ["previous", "전회금액", "전율"], + ["current", "금회금액", "금율"], + ["cumulative", "누계금액", "누율"], +]; + +interface Round { + quantities: Record; + vat_mode: string; + vat_manual_krw: string; +} + +interface AmountRow { + key: string; + name: string; + contract_krw: string; + contract_ratio_pct?: string | null; + [column: string]: string | null | undefined; +} + +interface ProgressRow { + item_no: string; + name: string; + spec: string; + unit: string; + quantity: string | null; + is_group: boolean; + in_bill: boolean; + contract_amount_krw?: string; + progress_pct?: string | null; + progress_remaining_quantity?: string; + progress_note?: string; + [column: string]: string | boolean | null | undefined; +} + +interface ProgressDto { + status: string; + message?: string; + rows: ProgressRow[]; + items: AmountRow[]; + summary: AmountRow[]; + contract_overhead_ratio_pct: string | null; + round: number; + round_count: number; + notes: string[]; + settings: { rounds: Round[] }; + fields: { vat: { key: string; label: string }[] }; + limit_note: string; +} + +const STYLE_ID = "b09-progress-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09pg { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09pg__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09pg__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09pg__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09pg__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09pg__table th, .b09pg__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09pg__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09pg__table tr.is-group td, .b09pg__table tr.is-total td { font-weight: 600; } +.b09pg__table input { width: 7em; text-align: right; } +.b09pg__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.b09pg__panel label { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +`; + 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 | boolean | null | undefined): string { + if (value === null || value === undefined || value === "" || typeof value === "boolean") + return ""; + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +function pct(value: string | boolean | null | undefined): string { + return typeof value === "string" ? `${value}%` : ""; +} + +/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */ +const drafts = new Map(); + +function draftOf(projectId: string, data: ProgressDto) { + let draft = drafts.get(projectId); + if (!draft) { + draft = { rounds: structuredClone(data.settings.rounds), round: data.round }; + drafts.set(projectId, draft); + } + return draft; +} + +function endpoint(projectId: string): string { + return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/progress`; +} + +async function fetchProgress(projectId: string, round: number | null): Promise { + const query = round ? `?round=${round}` : ""; + const response = await fetch(`${endpoint(projectId)}${query}`, { credentials: "include" }); + const body = (await response.json()) as ProgressDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveProgress(projectId: string, rounds: Round[]): Promise { + const response = await fetch(endpoint(projectId), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rounds }), + }); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number) => void): void { + const projectId = ctx.projectId as string; + const draft = draftOf(projectId, data); + const box = el("div", "b09pg__panel"); + const save = async (round: number): Promise => { + try { + await saveProgress(projectId, draft.rounds); + drafts.delete(projectId); + showToast("기성 회차 저장 — 계약 내역은 그대로", "success"); + reload(round); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }; + const pick = el("label"); + pick.append(el("span", "", "회차")); + const select = el("select"); + for (let n = 1; n <= draft.rounds.length; n += 1) { + const option = el("option", "", `${n}회`); + option.value = String(n); + select.append(option); + } + select.value = String(data.round); + select.addEventListener("change", () => reload(Number(select.value))); + pick.append(select); + box.append(pick); + box.append( + createButton({ + label: "회차 추가(저장)", + variant: "ghost", + onClick: () => { + const last = draft.rounds[draft.rounds.length - 1]; + draft.rounds.push({ + quantities: {}, + vat_mode: last?.vat_mode ?? "supply", + vat_manual_krw: "", + }); + void save(draft.rounds.length); + }, + }), + ); + const current = draft.rounds[data.round - 1]; + if (current) { + const vat = el("label"); + vat.append(el("span", "", "부가세")); + const mode = el("select"); + for (const choice of data.fields.vat) { + const option = el("option", "", choice.label); + option.value = choice.key; + mode.append(option); + } + mode.value = current.vat_mode; + const manual = el("input"); + manual.type = "number"; + manual.min = "0"; + manual.placeholder = "직접입력 금액"; + manual.value = current.vat_manual_krw; + manual.hidden = current.vat_mode !== "manual"; + mode.addEventListener("change", () => { + current.vat_mode = mode.value; + manual.hidden = mode.value !== "manual"; + }); + manual.addEventListener("input", () => (current.vat_manual_krw = manual.value)); + vat.append(mode, manual); + box.append(vat, createButton({ label: "저장", onClick: () => save(data.round) })); + } else { + box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터")); + } + ctx.panel.append(box); +} + +/** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */ +function drawJab(data: ProgressDto): HTMLElement { + const box = el("div"); + box.append( + el( + "strong", + "", + `기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`, + ), + ); + const table = el("table", "b09pg__table"); + const head = el("tr"); + for (const label of ["명칭", "도급액", "계약잡비율", ...COLUMNS.flatMap(([, a, r]) => [a, r])]) { + head.append(el("th", "", label)); + } + table.append(head); + const line = (row: AmountRow, total: boolean): void => { + const tr = el("tr", total ? "is-total" : ""); + tr.append( + el("td", "", row.name), + el("td", "num", won(row.contract_krw)), + el("td", "num", pct(row.contract_ratio_pct)), + ); + for (const [col] of COLUMNS) { + tr.append(el("td", "num", won(row[`${col}_krw`])), el("td", "num", pct(row[`${col}_pct`]))); + } + table.append(tr); + }; + for (const row of data.items) line(row, false); + for (const row of data.summary) line(row, true); + box.append(table); + return box; +} + +function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement { + const box = el("div"); + box.append(el("strong", "", `기성내역 — ${data.round ? `${data.round}회` : "회차 없음"}`)); + const table = el("table", "b09pg__table"); + const head = el("tr"); + for (const label of [ + "공종번호", + "명칭", + "규격", + "단위", + "계약수량", + "계약금액", + "전회수량", + "전회금액", + "금회수량", + "금회금액", + "누계수량", + "누계금액", + "기성(%)", + "잔량", + "비고", + ]) { + head.append(el("th", "", label)); + } + table.append(head); + const current = rounds[data.round - 1]; + for (const row of data.rows) { + const tr = el("tr", row.is_group ? "is-group" : ""); + const leaf = !row.is_group && row.in_bill && row.progress_current_quantity !== undefined; + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", leaf ? (row.quantity ?? "") : ""), + el("td", "num", won(row.contract_amount_krw)), + el("td", "num", leaf ? String(row.progress_previous_quantity) : ""), + el("td", "num", won(row.progress_previous_amount_krw)), + ); + const cell = el("td"); + if (leaf && current) { + const qty = el("input"); + qty.type = "number"; + qty.min = "0"; + qty.step = "any"; + qty.value = current.quantities[row.item_no] ?? ""; + qty.addEventListener("input", () => { + if (qty.value === "") delete current.quantities[row.item_no]; + else current.quantities[row.item_no] = qty.value; + }); + cell.append(qty); + } + tr.append( + cell, + el("td", "num", won(row.progress_current_amount_krw)), + el("td", "num", leaf ? String(row.progress_cumulative_quantity) : ""), + el("td", "num", won(row.progress_cumulative_amount_krw)), + el("td", "num", pct(row.progress_pct)), + el("td", "num", row.progress_remaining_quantity ?? ""), + el("td", "", row.progress_note ?? ""), + ); + table.append(tr); + } + box.append(table); + return box; +} + +function drawBody(ctx: B09TabContext, data: ProgressDto): void { + const draft = draftOf(ctx.projectId as string, data); + const wrap = el("div", "b09pg"); + wrap.append(el("div", "b09pg__warn", `⚠ ${data.limit_note}`)); + for (const note of data.notes) wrap.append(el("div", "b09pg__warn", `⚠ ${note}`)); + wrap.append(el("div", "b09pg__meta", "금회 기성수량을 넣고 [저장]하면 반영")); + const scroll = el("div", "b09pg__scroll"); + scroll.append(drawJab(data), drawBill(data, draft.rounds)); + wrap.append(scroll); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09pg__meta", "프로젝트를 고르세요")); + return; + } + const load = (round: number | null): void => { + ctx.body.replaceChildren( + el("div", "b09pg__meta", `${L("B09_Estimation_Tab_Progress")} 계산 중…`), + ); + ctx.panel.replaceChildren(); + fetchProgress(ctx.projectId as string, round) + .then((data) => { + const draft = drafts.get(ctx.projectId as string); + if (draft) draft.round = data.round; + ctx.body.replaceChildren(); + drawPanel(ctx, data, load); + drawBody(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09pg__warn", + `기성을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(drafts.get(ctx.projectId)?.round ?? null); +} + +export const progressTab: B09Tab = { + key: "progress", + label: () => L("B09_Estimation_Tab_Progress"), + render, +}; diff --git a/main.py b/main.py index 73b46797..a682f00f 100644 --- a/main.py +++ b/main.py @@ -67,6 +67,7 @@ from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_struct 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_Execution import router as b09_execution_router +from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress_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 @@ -644,6 +645,7 @@ 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_execution_router, dependencies=protected_with_company) +app.include_router(b09_progress_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) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/test_b09_progress.py b/resources/tester/test_b09_progress.py new file mode 100644 index 00000000..9edd502a --- /dev/null +++ b/resources/tester/test_b09_progress.py @@ -0,0 +1,158 @@ +"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (PLAN 12장 · 2026-09-14 배정). + +⚠ 값이 맞는지는 못 잰다 — 기성 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것: + ① ⭐ 제잡비 줄 = 금회직접공사비 × 계약잡비율(도급액 ÷ 계약 직접공사비) + — 줄마다 밑수×요율 다시 안 셈 + ② 계약 · 전회(앞 회차 금회의 합) · 금회 · 누계 네 칸과 각 비율 · 기성(%) · 잔량 + ③ 부가세 넷 — 직접입력 · 공급가액 · 재료비 · 재료비+산출경비 + ④ 계약 줄(입력) 불변 · 분리발주 폐기물은 제잡비 밖 · 틀린 칸 거름 +""" + +from __future__ import annotations + +import copy +import sys +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +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 + + +def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict: + total = sum(unit) * int(qty) + return { + "item_no": item_no, + "is_group": False, + "in_bill": True, + "quantity": qty, + "contract_unit_material_krw": str(unit[0]), + "contract_unit_labor_krw": str(unit[1]), + "contract_unit_expense_krw": str(unit[2]), + "contract_amount_krw": str(total), + } + + +CONTRACT_ROWS = [ + {"item_no": "1", "is_group": True, "in_bill": True}, + _contract_row("1.1", "100", (1000, 2000, 500)), # 350,000 + _contract_row("1.2", "10", (5000, 0, 0)), # 50,000 → 계약 직접공사비 400,000 +] + + +def _cost(separate_waste: bool = False) -> tuple[SimpleNamespace, CostResult]: + result = CostResult() + for key, amount in ( + ("indirect_labor_cost", 20000), + ("industrial_accident_insurance", 7000), + ("waste_disposal", 5000), + ("general_overhead", 24000), + ("profit", 30000), + ("vat", 48100), + ): + result.lines.append( + CostLine(key, key, "", Decimal(amount), None, Decimal(0), Decimal(amount)) + ) + data = SimpleNamespace(waste_separate_order=separate_waste, indirect_material_krw=Decimal(0)) + return data, result + + +SETTINGS = { + "rounds": [ + {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"}, + {"quantities": {"1.1": "30"}, "vat_mode": "material"}, + ] +} + + +def _sheet(settings=SETTINGS, round_no=None, separate_waste=True) -> dict: + data, result = _cost(separate_waste) + return progress_sheet(CONTRACT_ROWS, data, result, clean_settings(settings)[0], round_no) + + +def test_제잡비는_금회직접공사비_곱하기_계약잡비율() -> None: + before = copy.deepcopy(CONTRACT_ROWS) + sheet = _sheet() + assert CONTRACT_ROWS == before # 계약 줄 불변 + items = {item["key"]: item for item in sheet["items"]} + labor = items["indirect_labor_cost"] + # 도급액 20,000 ÷ 계약 직접 400,000 = 5% · 1회 150,000 → 7,500 · 2회 105,000 → 5,250 + assert (labor["contract_ratio_pct"], labor["previous_krw"], labor["current_krw"]) == ( + "5.000", + "7500", + "5250", + ) + assert (labor["cumulative_krw"], labor["cumulative_pct"]) == ("12750", "63.750") + # 7,000 × 105,000 ÷ 400,000 = 1,837.5 → 회차마다 원 미만 절사 + assert items["industrial_accident_insurance"]["current_krw"] == "1837" + assert "waste_disposal" not in items # 분리발주면 도급 밖 + assert sheet["contract_overhead_ratio_pct"] == "20.250" # 81,000 ÷ 400,000 + + +def test_네_칸과_기성율_잔량() -> None: + sheet = _sheet() + row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] + assert ( + row["progress_previous_quantity"], + row["progress_current_quantity"], + row["progress_cumulative_quantity"], + ) == ("40", "30", "70") + assert (row["progress_cumulative_amount_krw"], row["progress_pct"]) == ("245000", "70.000") + assert row["progress_remaining_quantity"] == "30" + group = sheet["rows"][0] + assert group["progress_cumulative_amount_krw"] == "255000" # 245,000 + 1.2 의 10,000 + summary = {s["key"]: s for s in sheet["summary"]} + assert (summary["direct"]["previous_krw"], summary["direct"]["current_krw"]) == ( + "150000", + "105000", + ) + assert summary["direct"]["cumulative_pct"] == "63.750" + + +def test_부가세_넷() -> None: + summary = {s["key"]: s for s in _sheet()["summary"]} + # 1회 공급가액 180,375 × 10% = 18,037 · 2회 재료비 30,000 × 10% = 3,000 + assert (summary["supply"]["previous_krw"], summary["vat"]["previous_krw"]) == ( + "180375", + "18037", + ) + assert summary["vat"]["current_krw"] == "3000" + coop = {"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "forest_coop"}]} + vat = {s["key"]: s for s in _sheet(coop)["summary"]}["vat"]["current_krw"] + assert vat == "6000" # (재료비 40,000 + 산출경비 20,000) × 10% + manual = { + "rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "manual", "vat_manual_krw": "12345"}] + } + assert {s["key"]: s for s in _sheet(manual)["summary"]}["vat"]["current_krw"] == "12345" + + +def test_회차를_고르면_그_앞이_전회() -> None: + first = _sheet(round_no=1) + row = {r["item_no"]: r for r in first["rows"]}["1.1"] + assert (row["progress_previous_quantity"], row["progress_current_quantity"]) == ("0", "40") + assert first["round"] == 1 and first["round_count"] == 2 + + +def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_제잡비() -> None: + over = {"rounds": [{"quantities": {"1.2": "12"}, "vat_mode": "supply"}]} + row = {r["item_no"]: r for r in _sheet(over)["rows"]}["1.2"] + assert row["progress_remaining_quantity"] == "-2" and "넘음" in row["progress_note"] + items = {item["key"] for item in _sheet(separate_waste=False)["items"]} + assert "waste_disposal" in items + + +def test_틀린_칸은_거른다() -> None: + _, errors = clean_settings( + { + "rounds": [ + {"quantities": {"1.1": "-1"}, "vat_mode": "supply"}, + {"quantities": {}, "vat_mode": "manual", "vat_manual_krw": ""}, + {"quantities": {}, "vat_mode": "없는방식"}, + ] + } + ) + assert len(errors) == 3 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index ddece2fe..95ca65d0 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -10,4 +10,5 @@ export const ui_locales_b4 = { B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"], B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"], B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"], + B09_Estimation_Tab_Progress: ["기성", "Progress Payment"], } as const;