From 3257fe9755289f4e573a8ce509de40b7fceb6636 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:07:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EA=B8=B0=EC=B4=88=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EB=84=A4=20=ED=91=9C=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=85=B8=EB=AC=B4=EB=B9=84=C2=B7=EC=9E=AC=EB=A3=8C?= =?UTF-8?q?=EB=B9=84=C2=B7=EA=B2=BD=EB=B9=84=20=EB=AA=A9=EB=A1=9D=ED=91=9C?= =?UTF-8?q?=20+=20=EC=A4=91=EA=B8=B0=EB=AA=A9=EB=A1=9D=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 12번 「내야 할 표 16개 전체」. 안 내던 일곱 중 넷을 냄. - 서식은 지어내지 않고 실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 옮김 — 코드번호·명칭·규격·단위·단가·비고. 중기목록표만 합계 + 3분할(노무·재료·경비). - ⚠ 경비목록표는 취득가(천원)이지 시간당 손료가 아님. 실무 실측 「S00104 불도저(무한궤도) 19톤 천원 184,499」와 자릿수를 맞춤 — 손료를 실으면 세 자리 어긋난 채 「경비」로 읽힘. - 자원 집계표(역집계)도 함께 냄. 일위대가 안쪽을 한 겹만 폄 — 기계 사용료를 다시 손료·연료로 쪼개면 중기 집계표와 이중계상이 됨. 비율 줄(제잡비)은 자원으로 안 셈. - 집계표는 반올림, 내역서 본체는 절사 — 어긋나는 것이 정상임을 문구로 함께 냄. - 새 계산 없음. PriceBook 자료를 접기만 함. 실무 대조(2024 대 2026 자료): 보통인부 165,545→172,068 · 불도저19 취득가 184,499→198,150 · 시간당 126,029→124,842. 연도 차이 범위 안. 라우터 조회 하나 추가(GET .../estimation/base-data). 시험 8건, 262건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Lists.py | 228 ++++++++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 19 ++ 2 files changed, 247 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_Lists.py diff --git a/B09_Estimation/B09_Estimation_Lists.py b/B09_Estimation/B09_Estimation_Lists.py new file mode 100644 index 00000000..6a1ebc62 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Lists.py @@ -0,0 +1,228 @@ +"""B09 원가계산 — **목록표·집계표** (사용자 확정 12번: 내야 할 표 16개 전체). + +지금까지 안 내던 일곱 표 중 여섯이 여기서 난다. + + A5-1 중기목록표 코드·명칭·규격·단위 · **합계·노무비·재료비·경비** · 비고 + A6 노무비목록표 코드·명칭·규격·단위 · **단가** · 비고 + A7 재료비목록표 〃 + A8 경비목록표 〃 (기계 취득가 `S-` 층이 여기 온다) + A11 자원 집계표 코드·명칭·규격 · **수량** · 단위 · 단가 · **금액** · 비고 + — 노무비·재료비·경비·중기 네 벌 + +**서식은 지어내지 않았다** — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를 +그대로 옮겼다(2026-09-09 실측). 칸 이름·차례가 그 시트와 같다. + +⚠ **새 계산이 아니다.** 목록표는 `PriceBook` 의 제목을 종류별로 늘어놓는 것이고, +집계표는 **내역서에 이미 선 금액을 자원별로 되모으는 것**이다. 값을 여기서 다시 만들면 +내역서와 어긋난다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」). + +⚠ **집계표는 반올림**이다(단수 규칙 `RESOURCE_SUMMARY`). 내역서 본체는 절사라 +**두 표의 합이 원 단위로 어긋나는 것이 정상**이다 — 그 사실을 화면에 함께 낸다. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_Rounding import ( + SUMMARY_MISMATCH_NOTE, + OutputPlace, + round_at, +) +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build + +_ZERO = Decimal(0) + +#: 목록표 한 장이 담는 종류. 실무 시트 이름 그대로 쓴다. +LIST_KINDS: tuple[tuple[str, str, PriceKind], ...] = ( + ("labor", "노무비목록표", PriceKind.LABOR), + ("material", "재료비목록표", PriceKind.MATERIAL), + ("expense", "경비목록표", PriceKind.MACHINE_BASE), +) + + +def _money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + +def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]: + """목록표 한 장 — 그 종류의 **기초단가 줄**을 코드 차례로 늘어놓는다. + + ⚠ 단가가 안 선 줄도 **빼지 않는다.** 빼면 「없는 것」과 「값을 못 구한 것」이 같아 보인다. + """ + rows: list[dict[str, Any]] = [] + for code in sorted(build.book.titles): + title = build.book.titles[code] + if title.kind is not kind: + continue + try: + price: Decimal | None = title.adopted_price() + note = "" + except Exception as error: # 채택 슬롯이 비었다 — 값을 지어내지 않는다 + price, note = None, str(error) + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "unit_price_krw": _money(price), + "note": note, + } + ) + return rows + + +def machine_base_list() -> list[dict[str, Any]]: + """경비목록표 — **기계 취득가격(천원)** 목록. + + ⚠ 내 `S-` 층과 **다른 값**이다. `S-` 는 「취득가 × 시간당 손료계수」라 **원/시간**이고, + 실무 경비목록표는 **취득가 그 자체를 천원 단위**로 싣는다(영월 실측: + `S00104 불도저(무한궤도) 19톤 **천원** 184,499`). 손료를 여기 실으면 자릿수가 세 자리 + 어긋난 채 「경비」로 읽힌다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog() + rows: list[dict[str, Any]] = [] + for code in sorted(catalog.machines): + machine = catalog.machines[code] + rows.append( + { + "code": f"S-{code}", + "name": machine.name, + "spec": machine.specification, + "unit": "천원", + "unit_price_krw": _money(machine.price_thousand_krw), + "note": "" if machine.loss_coefficient_per_hour is not None else "손료계수 미확보", + } + ) + return rows + + +def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]: + """중기목록표 — 시간당 사용료를 **3분할까지** 보인다 (실무 시트와 같은 칸). + + 실무 서식: `X00205 굴삭기(무한궤도) 0.7㎥ 시간 96,843 = 노무 55,700 + 재료 18,015 + 경비 23,128` + """ + rows: list[dict[str, Any]] = [] + for code in sorted(build.book.titles): + title = build.book.titles[code] + if title.kind is not PriceKind.MACHINE_HOURLY: + continue + try: + money = build.book.resolve(code) + row = { + "total_krw": _money(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), + "labor_krw": _money(round_at(money.labor, OutputPlace.UNIT_PRICE_ROW)), + "material_krw": _money(round_at(money.material, OutputPlace.UNIT_PRICE_ROW)), + "expense_krw": _money(round_at(money.expense, OutputPlace.UNIT_PRICE_ROW)), + "note": "", + } + except Exception as error: # 층이 덜 섰다 — 0 으로 안 때운다 + row = { + "total_krw": None, + "labor_krw": None, + "material_krw": None, + "expense_krw": None, + "note": str(error), + } + rows.append( + {"code": code, "name": title.name, "spec": title.spec, "unit": title.unit, **row} + ) + return rows + + +def resource_summary( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, +) -> dict[str, Any]: + """자원 집계표 — 공종 수량을 **자원별로 되모은다**. + + `quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양). + 한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다. + + ⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가 + 실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 + 이중으로 세는 것**이 된다. + """ + prices = build or cached_build() + book = prices.book + #: 자원코드 → [수량, 제목] + picked: dict[str, list[Any]] = {} + missing: list[str] = [] + + for raw_code, quantity in quantities.items(): + code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" + if code not in book.titles: + missing.append(raw_code) + continue + amount = Decimal(str(quantity)) + for detail in book.details.get(code, []): + if detail.percent_of_labor is not None or detail.percent_of_parent is not None: + continue # 비율 줄은 자원이 아니다 — 경비로만 붙는다 + ref = detail.ref_code + if ref == code: + continue + slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)]) + slot[0] += detail.quantity * amount + + groups: dict[str, list[dict[str, Any]]] = { + "labor": [], + "material": [], + "expense": [], + "machine": [], + } + for ref, (amount, title) in sorted(picked.items()): + if title is None: + missing.append(ref) + continue + bucket = { + PriceKind.LABOR: "labor", + PriceKind.MATERIAL: "material", + PriceKind.MACHINE_BASE: "expense", + PriceKind.MACHINE_HOURLY: "machine", + }.get(title.kind) + if bucket is None: + continue + try: + unit_money = book.resolve(ref) + unit_price: Decimal | None = unit_money.total + # ⚠ 집계표는 **반올림** — 내역서 본체(절사)와 원 단위로 어긋나는 것이 정상이다. + money: Decimal | None = round_at( + unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY + ) + note = "" + except Exception as error: + unit_price, money, note = None, None, str(error) + groups[bucket].append( + { + "code": ref, + "name": title.name, + "spec": title.spec, + "quantity": str(amount), + "unit": title.unit, + "unit_price_krw": _money(unit_price), + "amount_krw": _money(money), + "note": note, + } + ) + + return { + "groups": groups, + "missing": sorted(set(missing)), + "note": SUMMARY_MISMATCH_NOTE, + } + + +def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]: + """목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다.""" + prices = build or cached_build() + return { + "labor": catalog_list(prices, PriceKind.LABOR), + "material": catalog_list(prices, PriceKind.MATERIAL), + "expense": machine_base_list(), + "machine": machine_list(prices), + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index bf58a57c..816aee63 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -217,6 +217,25 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/base-data") +async def get_base_data_lists(project_id: UUID) -> JSONResponse: + """**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번). + + 별표2 설계서 구성에 드는 표들이라 **없으면 설계서가 성립하지 않는다.** 서식은 + 실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 따랐다. + """ + from B09_Estimation.B09_Estimation_Lists import all_lists + + try: + return JSONResponse(content={"status": "success", **all_lists(cached_build())}) + except Exception: + logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "기초자료 목록을 못 만들었습니다."}, + ) + + @router.get("/{project_id}/estimation/unit-prices/{code}") async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""