From 83c842e37a9b6137037dbaeb38d05486d7c4ac6e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 20:30:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EA=B3=B5=EA=B5=AC=EC=86=90?= =?UTF-8?q?=EB=A3=8C=C2=B7=EC=9E=A1=EC=9E=AC=EB=A3=8C=20=EC=B9=B8=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B0=EB=B3=B8=EC=9D=80=20=EB=B9=94,=20?= =?UTF-8?q?=EB=84=A3=EC=9C=BC=EB=A9=B4=20=EC=A3=BC=EC=9E=AC=EB=A3=8C?= =?UTF-8?q?=EB=B9=84=EC=9D=98=20%=EB=A1=9C=20=EB=B6=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」. 근거는 산림품셈 1-2-6 — 주재료비(할증수량 제외)의 2~5%까지, 산정 근거 명시. - 기초자료 탭 「산출 조건」에 칸 하나 + [적용]. 비면 줄 자체가 안 섬(지금 상태 그대로). - 밑수는 **자재 줄만** — 노무·경비, 하위 일위대가 재료비는 안 듦(층마다 거듭 세지 않음). - 상한 5% 초과는 거절(400) — 조용히 깎아 넣지 않음. - ⚠ 지금은 일위대가에 주재료비가 선 공종이 0개라 붙을 밑수가 없음 — 그 사실을 칸 밑에 띄움. - 곁다리: 비율 줄(제잡비·공구손료)이 저장했다 읽으면 사라지던 것을 고침. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Lists.py | 8 +- B09_Estimation/B09_Estimation_PriceBook.py | 42 +++++ B09_Estimation/B09_Estimation_Router.py | 50 +++++- B09_Estimation/B09_Estimation_Storage.py | 12 ++ B09_Estimation/B09_Estimation_UI_BaseData.ts | 91 +++++++++- B09_Estimation/B09_Estimation_UnitPrice.py | 55 ++++++ .../B09_Estimation_UnitPrice_View.py | 31 ++++ resources/tester/test_b09_misc_material.py | 161 ++++++++++++++++++ 8 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_b09_misc_material.py diff --git a/B09_Estimation/B09_Estimation_Lists.py b/B09_Estimation/B09_Estimation_Lists.py index 6a1ebc62..47a38509 100644 --- a/B09_Estimation/B09_Estimation_Lists.py +++ b/B09_Estimation/B09_Estimation_Lists.py @@ -161,8 +161,12 @@ def resource_summary( 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 # 비율 줄은 자원이 아니다 — 경비로만 붙는다 + if ( + detail.percent_of_labor is not None + or detail.percent_of_parent is not None + or detail.percent_of_material is not None + ): + continue # 비율 줄은 자원이 아니다 — 목록표에 설 자재·노임이 없다 ref = detail.ref_code if ref == code: continue diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index afb10796..a54c971f 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -152,6 +152,12 @@ class PriceDetail: #: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고, #: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다. percent_of_labor: Decimal | None = None + #: **주재료비**의 %로 붙는 재료비 줄 — 공구손료·잡재료(산림품셈 1-2-6). + #: ⚠ 밑수는 **자재 카탈로그(M)에서 온 재료비만**이다. 하위 일위대가가 품고 온 재료비는 + #: 그 일위대가에서 이미 한 번 셌으므로 여기서 또 세지 않는다. + #: ⚠ 원문이 「재료비의 **할증수량 제외**」라 밑수가 **할증 전** 값이어야 하는데, 일위대가 + #: 층의 재료비가 곧 할증 전 값이다(할증은 자재총괄에서 한 번만 — PLAN 8-7 ㉠). + percent_of_material: Decimal | None = None @dataclass @@ -200,6 +206,8 @@ class PriceBook: # 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다. # 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋. direct_labor = Decimal(0) + # 공구손료·잡재료 밑수로 쓸 **주재료비** — 자재 카탈로그에서 바로 온 재료비만 센다. + direct_material = Decimal(0) for row in rows: # ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로 # 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다). @@ -236,6 +244,20 @@ class PriceBook: total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100)) continue + if row.percent_of_material is not None: + # 공구손료·잡재료 — **주재료비**의 %가 **재료비**로 붙는다(산림품셈 1-2-6 + # 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 … 주재료비(재료비의 + # 할증수량 제외)의 2~5%까지 별도 계상하되 산정 근거를 명시하여야 한다」). + # ⚠ **기본은 빈 칸이라 이 줄 자체가 안 선다**(사용자 확정 5차 작은 것 1 + # 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」). 몇 %인지는 사용자 몫이다. + # ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는 + # 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼 + # 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.** + total = total + Money3( + material=direct_material * row.percent_of_material / Decimal(100) + ) + continue + child = self.resolve(row.ref_code, (*_seen, code)) if row.percent_of_parent is not None: # 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등). @@ -244,9 +266,29 @@ class PriceBook: scaled = child.scaled(row.quantity) if self.titles[row.ref_code].kind is PriceKind.LABOR: direct_labor = direct_labor + scaled.labor + if self.titles[row.ref_code].kind is PriceKind.MATERIAL: + direct_material = direct_material + scaled.material total = total + scaled return total + def material_base(self, code: str) -> Decimal: + """공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합. + + `resolve` 안의 `direct_material` 과 같은 규칙이다. 화면이 「넣을 데가 있는가」를 + 미리 물어볼 수 있게 따로 낸다 — 지금은 사급 단가가 미결이라 대부분 0 이고, + 그 사실을 숨기면 사용자가 값을 넣고도 왜 안 붙는지 모른다. + """ + base = _ZERO + for row in self.details.get(code, []): + if row.percent_of_material is not None or row.percent_of_labor is not None: + continue + if row.percent_of_parent is not None or row.ref_code == code: + continue + title = self.titles.get(row.ref_code) + if title is not None and title.kind is PriceKind.MATERIAL: + base = base + title.catalog_money().material * row.quantity + return base + def unmatched_codes(self) -> list[str]: """상세가 가리키는데 제목이 없는 코드 — **빈칸으로 두지 않고 목록으로 낸다**. diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 2d66db13..aef30aa5 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -247,7 +247,8 @@ async def _build_for(project_id: UUID): machines = tuple( sorted((str(k), str(v)) for k, v in (settings.get("machine_choices") or {}).items()) ) - return cached_build(ranges, machines) + # 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1). + return cached_build(ranges, machines, str(settings.get("misc_material_percent") or "")) @router.get("/{project_id}/estimation/base-data") @@ -370,11 +371,46 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: } ) + from B09_Estimation.B09_Estimation_PriceBook import PriceKind + from B09_Estimation.B09_Estimation_UnitPrice import ( + MISC_MATERIAL_MAX_PERCENT, + MISC_MATERIAL_MIN_PERCENT, + ) + + # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. + # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. + book = (await _build_for(project_id)).book + with_material = sum( + 1 + for unit_code, unit_title in book.titles.items() + if unit_title.kind is PriceKind.UNIT_PRICE and book.material_base(unit_code) > 0 + ) + return JSONResponse( content={ "status": "success", "ranges": ranges, "machines": machines, + "misc_material": { + "percent": str(settings.get("misc_material_percent") or ""), + "min": str(MISC_MATERIAL_MIN_PERCENT), + "max": str(MISC_MATERIAL_MAX_PERCENT), + "basis": [ + "산림품셈 1-2-6 — 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 등을" + " 계상하고자 할 때에는 주재료비(재료비의 할증수량 제외)의 2~5%까지" + " 별도 계상하되 산정 근거를 명시하여야 한다」", + "⚠ 비워 두면 안 붙습니다 — 지금은 안 붙고 있는 상태입니다" + " (사용자 확정 2026-09-09 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」).", + ], + "base_items": with_material, + "base_note": ( + "" + if with_material + else "⚠ 지금은 일위대가에 주재료비가 선 공종이 하나도 없습니다" + " — 자재는 자재대 표에서 따로 금액이 섭니다. 값을 넣어도 붙을 밑수가" + " 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다." + ), + }, "notes": [ "고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.", "바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.", @@ -394,6 +430,8 @@ class FactorChoiceBody(BaseModel): range_factor_choices: dict[str, str] | None = None machine_choices: dict[str, str] | None = None + #: 공구손료·잡재료 비율 — **빈 문자열이면 안 붙는다**(칸을 도로 비우는 길). + misc_material_percent: str | None = None @router.put("/{project_id}/estimation/factors") @@ -422,6 +460,16 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe for key, value in body.machine_choices.items() if str(value) in MACHINE_OPTION_CODES } + if body.misc_material_percent is not None: + from B09_Estimation.B09_Estimation_UnitPrice import parse_misc_material_percent + + try: + percent = parse_misc_material_percent(body.misc_material_percent) + except ValueError as exc: + # ⚠ 조용히 깎아 넣지 않는다 — 범위 밖 값을 상한으로 접으면 사용자가 넣은 값과 + # 금액이 어긋난 채로 선다. + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + values["misc_material_percent"] = "" if percent is None else str(percent) try: save_section(root, "estimation", values, replace_keys=tuple(values)) return JSONResponse(content={"status": "success", **values}) diff --git a/B09_Estimation/B09_Estimation_Storage.py b/B09_Estimation/B09_Estimation_Storage.py index 70b18a9c..2d7bb7ac 100644 --- a/B09_Estimation/B09_Estimation_Storage.py +++ b/B09_Estimation/B09_Estimation_Storage.py @@ -119,17 +119,29 @@ def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]: "percent_of_parent": ( None if detail.percent_of_parent is None else str(detail.percent_of_parent) ), + # ⚠ 비율 줄 셋을 **다 적는다** — 하나라도 빠뜨리면 저장했다 다시 읽을 때 그 줄이 + # 조용히 사라져 단가가 낮아진다(제잡비가 그 자리였다). + "percent_of_labor": ( + None if detail.percent_of_labor is None else str(detail.percent_of_labor) + ), + "percent_of_material": ( + None if detail.percent_of_material is None else str(detail.percent_of_material) + ), } def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail: percent = raw.get("percent_of_parent") + labor_percent = raw.get("percent_of_labor") + material_percent = raw.get("percent_of_material") return PriceDetail( parent_code=raw["parent_code"], ref_code=raw["ref_code"], quantity=Decimal(str(raw.get("quantity", "0"))), note=raw.get("note", ""), percent_of_parent=None if percent is None else Decimal(str(percent)), + percent_of_labor=None if labor_percent is None else Decimal(str(labor_percent)), + percent_of_material=(None if material_percent is None else Decimal(str(material_percent))), ) diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index b55f76b5..88d3fe49 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -486,10 +486,22 @@ export interface MachineChoiceRow { basis: string[]; } +/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙는다(산림품셈 1-2-6). */ +export interface MiscMaterialRow { + percent: string; + min: string; + max: string; + basis: string[]; + /** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */ + base_items: number; + base_note: string; +} + export interface FactorChoicesDto { status: string; ranges: RangeFactorRow[]; machines: MachineChoiceRow[]; + misc_material?: MiscMaterialRow; notes: string[]; } @@ -504,7 +516,11 @@ export async function fetchFactorChoices(projectId: string): Promise; machine_choices?: Record }, + body: { + range_factor_choices?: Record; + machine_choices?: Record; + misc_material_percent?: string; + }, ): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, @@ -515,7 +531,56 @@ export async function saveFactorChoices( body: JSON.stringify(body), }, ); - if (!response.ok) throw new Error(`factors save ${response.status}`); + if (!response.ok) { + const message = await response + .json() + .then((body: { message?: string }) => body.message ?? "") + .catch(() => ""); + throw new Error(message || `factors save ${response.status}`); + } +} + +/** + * 숫자 칸 하나 — **빈 칸이 기본**이다. [적용]을 눌러야 저장된다. + * + * ⚠ 고르는 칸(`picker`)과 달리 여기는 **사용자가 값을 짓는 자리**라 누를 때만 보낸다 — + * 타자 한 자마다 보내면 「2」를 치는 도중에 2% 로 저장돼 버린다. + */ +function percentBox( + label: string, + value: string, + placeholder: string, + onApply: (text: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-hint"; + wrap.style.display = "flex"; + wrap.style.alignItems = "center"; + wrap.style.gap = "8px"; + wrap.style.flexWrap = "wrap"; + + const name = document.createElement("span"); + name.style.fontWeight = "600"; + name.textContent = label; + + const input = document.createElement("input"); + input.type = "number"; + input.step = "0.1"; + input.min = "0"; + input.value = value; + input.placeholder = placeholder; + input.style.width = "72px"; + + const unit = document.createElement("span"); + unit.textContent = "%"; + + const apply = document.createElement("button"); + apply.type = "button"; + apply.textContent = "적용"; + apply.addEventListener("click", () => onApply(input.value.trim())); + + wrap.append(name, input, unit, apply); + return wrap; } function picker( @@ -599,5 +664,27 @@ export function drawFactorChoices( for (const line of row.basis) body.append(note(line)); } + const misc = data.misc_material; + if (misc) { + body.append( + percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) => { + void saveFactorChoices(projectId, { misc_material_percent: text }) + .then(reload) + .catch((error: Error) => { + body.append(note(`⚠ ${error.message}`)); + }); + }), + ); + body.append( + note( + misc.percent + ? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.` + : `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`, + ), + ); + if (misc.base_note) body.append(note(misc.base_note)); + for (const line of misc.basis) body.append(note(line)); + } + for (const line of data.notes) body.append(note(line)); } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 4fdc6e3d..c9e43d18 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -22,6 +22,7 @@ from dataclasses import dataclass, field from dataclasses import replace as dataclass_replace from decimal import Decimal from functools import lru_cache +from typing import Any from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_MachineCost import ( @@ -157,6 +158,40 @@ _ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게") #: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원). COMBINED_MISC_PERCENT = Decimal(16) +#: ⭐ 사용자 확정 5차 작은 것 1 — **공구손료·잡재료 칸**. +#: 「지금은 안 넣음. 다만 숫자를 넣으면 되게 열어 둘 것 — 칸을 만들되 기본은 빔, 비면 안 붙음」 +#: 근거는 산림품셈 1-2-6 — 「각 항목에 명시되어 있는 잡재료 및 소모재료에 대해서는 이를 +#: 계상하고, 명시되어 있지 않는 … 주재료비(재료비의 할증수량 제외)의 **2~5%까지** 별도 +#: 계상하되 **산정 근거를 명시**하여야 한다」. +#: ⚠ **몇 %인지는 사용자 몫이다** — 기본값을 만들어 두지 않는다(범위값을 임의로 굳히면 +#: 금액이 조용히 그 값으로 선다). 비어 있으면 줄 자체가 안 서고 지금 상태 그대로다. +MISC_MATERIAL_MAX_PERCENT = Decimal(5) # 「2~5%**까지**」 — 상한 +MISC_MATERIAL_MIN_PERCENT = Decimal(2) # 원문이 적은 아랫값(아래로 내려가면 사유로 알린다) +MISC_MATERIAL_BASIS = "공구손료·잡재료 — 주재료비의 {percent}% (산림품셈 1-2-6, 산정 근거 명시)" + + +def parse_misc_material_percent(raw: Any) -> Decimal | None: + """설정 칸의 값을 비율로 읽는다. **비면 `None`**(= 안 붙음). + + ⚠ 상한(5%)을 넘는 값은 **받지 않는다** — 품셈이 「2~5%까지」로 못 박은 자리다. + """ + text = str(raw or "").strip().rstrip("%").strip() + if not text: + return None + try: + percent = Decimal(text) + except (ArithmeticError, ValueError): + raise ValueError(f"공구손료·잡재료 비율을 숫자로 못 읽었습니다: {raw!r}") from None + if percent <= 0: + return None + if percent > MISC_MATERIAL_MAX_PERCENT: + raise ValueError( + f"공구손료·잡재료는 주재료비의 {MISC_MATERIAL_MAX_PERCENT}% 까지입니다" + f" (산림품셈 1-2-6) — 받은 값 {percent}%" + ) + return percent + + #: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다. #: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 · #: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게. @@ -487,6 +522,7 @@ def build_unit_prices( axis: AxisResult | None = None, factor_choices: dict[tuple[str, str], Decimal] | None = None, machine_picks: dict[str, str] | None = None, + misc_material_percent: Decimal | None = None, ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -495,6 +531,9 @@ def build_unit_prices( ⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면 **평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다. + + ⚠ `misc_material_percent` — 공구손료·잡재료(산림품셈 1-2-6). **안 주면 줄이 안 선다** — + 사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다. """ from B09_Estimation.B09_Estimation_FactorChoices import ( chosen_values, @@ -698,6 +737,20 @@ def build_unit_prices( ) ) + # 공구손료·잡재료 — **주재료비의 %가 재료비로** 붙는다(산림품셈 1-2-6). + # ⚠ **비어 있으면 이 줄이 아예 안 선다** = 지금까지와 같은 금액이다(확정 5차 작은 것 1). + # ⚠ 갈래 제목(`__` 로 시작하는 내부 갈래)에는 안 붙인다 — 제잡비와 같은 자리를 쓴다. + if misc_material_percent is not None and not variant_key.startswith("__"): + build.book.add_detail( + PriceDetail( + title_code, + title_code, + _ZERO, + note=MISC_MATERIAL_BASIS.format(percent=misc_material_percent), + percent_of_material=misc_material_percent, + ) + ) + # 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4). # ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번 # 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다. @@ -892,6 +945,7 @@ DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, Pri def cached_build( range_choices: tuple[tuple[str, str], ...] = (), machine_picks: tuple[tuple[str, str], ...] = (), + misc_material_percent: str = "", ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -913,6 +967,7 @@ def cached_build( return build_unit_prices( factor_choices=chosen_values(scan_range_factors(master), settings), machine_picks=machine_choices(settings), + misc_material_percent=parse_misc_material_percent(misc_material_percent), ) diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index e541b4a8..9e41ef49 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -211,6 +211,37 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: ) continue + if detail.percent_of_material is not None: + # 공구손료·잡재료 — 지금까지 쌓인 **주재료비**의 %가 재료비로 붙는다 + # (산림품셈 1-2-6). 밑수는 **자재 줄만** — 하위 일위대가가 품고 온 재료비는 + # 그쪽에서 이미 셌다(`PriceBook.resolve` 의 같은 자리와 한 규칙). + material_so_far = sum( + ( + Decimal(str(row_item["material"])) + for row_item in rows + if row_item.get("kind") == PriceKind.MATERIAL.value + ), + _ZERO, + ) + amount = material_so_far * detail.percent_of_material / Decimal(100) + rows.append( + { + "code": detail.ref_code, + "name": "공구손료·잡재료", + "spec": f"주재료비의 {detail.percent_of_material}%", + "unit": "%", + "quantity": str(detail.percent_of_material), + "material": str(amount), + "labor": "0", + "expense": "0", + "total": _money_text(amount), + "source": "품셈 1-2-6", + "drillable": False, + "note": detail.note, + } + ) + continue + child = build.book.title(detail.ref_code) unit_money = build.book.resolve(detail.ref_code) line = unit_money.scaled(detail.quantity) diff --git a/resources/tester/test_b09_misc_material.py b/resources/tester/test_b09_misc_material.py new file mode 100644 index 00000000..978be03a --- /dev/null +++ b/resources/tester/test_b09_misc_material.py @@ -0,0 +1,161 @@ +"""공구손료·잡재료 칸 — 「지금은 안 넣되 숫자 넣으면 되게」 (2026-09-09 확정 5차 작은 것 1). + +근거는 산림품셈 1-2-6 — 「명시되어 있지 않는 잡재료 및 소모재료 등을 계상하고자 할 때에는 +**주재료비(재료비의 할증수량 제외)의 2~5%까지** 별도 계상하되 산정 근거를 명시하여야 한다」. + +⚠ 겨누는 것 다섯 + ① **비면 줄이 아예 안 선다** — 지금 금액이 한 원도 안 움직여야 함 + ② 넣으면 **재료비**로 붙는다(경비 아님) — 1-2-6 은 잡재료·소모재료 자리임 + ③ 밑수는 **주재료비만** — 노무·경비는 안 들고, 하위 일위대가 재료비도 안 듦 + (그쪽에서 이미 한 번 셌음 — 두 번 세면 층이 깊을수록 부풀어 오름) + ④ **상한 5% 를 넘는 값은 안 받는다** — 조용히 깎아 넣지도 않음 + ⑤ 저장했다 다시 읽어도 그 줄이 살아 있다 (비율 줄이 직렬화에서 빠지면 조용히 싸짐) +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) +from B09_Estimation.B09_Estimation_Storage import ( # noqa: E402 + _detail_from_dict, + _detail_to_dict, +) +from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402 + MISC_MATERIAL_MAX_PERCENT, + parse_misc_material_percent, +) + + +def _book(percent: Decimal | None) -> PriceBook: + """자재 100,000 + 노임 50,000 짜리 일위대가 한 줄.""" + book = PriceBook() + book.add_title( + PriceTitle(code="M-1", kind=PriceKind.MATERIAL, name="자재", slots=[Decimal(100_000)] * 6) + ) + book.add_title( + PriceTitle(code="L-1", kind=PriceKind.LABOR, name="보통인부", slots=[Decimal(50_000)] * 6) + ) + book.add_title(PriceTitle(code="B-1", kind=PriceKind.UNIT_PRICE, name="시험공종")) + book.add_detail(PriceDetail("B-1", "M-1", Decimal(1))) + book.add_detail(PriceDetail("B-1", "L-1", Decimal(1))) + if percent is not None: + book.add_detail(PriceDetail("B-1", "B-1", Decimal(0), percent_of_material=percent)) + return book + + +def test_비면_한_원도_안_움직인다() -> None: + """① 「지금은 안 넣음」 — 칸이 비면 줄 자체가 안 서야 함.""" + money = _book(None).resolve("B-1") + assert money.material == Decimal(100_000) + assert money.total == Decimal(150_000) + + +def test_넣으면_재료비로_붙는다() -> None: + """② 경비가 아니라 재료비 — 1-2-6 은 잡재료·소모재료 자리.""" + money = _book(Decimal(3)).resolve("B-1") + assert money.material == Decimal(103_000) + assert money.labor == Decimal(50_000) + assert money.expense == Decimal(0) + + +def test_밑수는_주재료비만이다() -> None: + """③ 노무비가 밑수에 들면 3% 가 4.5% 처럼 서게 됨.""" + money = _book(Decimal(3)).resolve("B-1") + 붙은값 = money.material - Decimal(100_000) + assert 붙은값 == Decimal(100_000) * Decimal(3) / Decimal(100) + + +def test_하위_일위대가_재료비는_밑수에_안_든다() -> None: + """③ 층이 깊어질수록 같은 재료비를 거듭 세면 안 됨.""" + book = _book(Decimal(3)) + book.add_title(PriceTitle(code="B-2", kind=PriceKind.UNIT_PRICE, name="윗공종")) + book.add_detail(PriceDetail("B-2", "B-1", Decimal(1))) + book.add_detail(PriceDetail("B-2", "B-2", Decimal(0), percent_of_material=Decimal(3))) + # B-1 이 품고 온 재료비(103,000)는 B-2 의 밑수가 아니다 — B-2 엔 제 자재 줄이 없다. + assert book.resolve("B-2").material == book.resolve("B-1").material + + +def test_상한을_넘으면_안_받는다() -> None: + """④ 「2~5%까지」 — 넘는 값을 조용히 깎아 넣지 않고 거절함.""" + with pytest.raises(ValueError) as caught: + parse_misc_material_percent("6") + assert str(MISC_MATERIAL_MAX_PERCENT) in str(caught.value) + assert parse_misc_material_percent("") is None + assert parse_misc_material_percent(" ") is None + assert parse_misc_material_percent("3.5%") == Decimal("3.5") + + +def test_밑수가_있는지_미리_물어볼_수_있다() -> None: + """화면이 「넣을 데가 있는가」를 물어보는 자리 — 없으면 0 이라고 말해야 함.""" + book = _book(None) + assert book.material_base("B-1") == Decimal(100_000) + book.add_title(PriceTitle(code="B-9", kind=PriceKind.UNIT_PRICE, name="자재 없는 공종")) + book.add_detail(PriceDetail("B-9", "L-1", Decimal(1))) + assert book.material_base("B-9") == Decimal(0) + + +def test_저장했다_읽어도_비율_줄이_산다() -> None: + """⑤ 직렬화에서 빠지면 다시 읽은 단가가 조용히 싸짐.""" + original = PriceDetail( + "B-1", "B-1", Decimal(0), note="공구손료", percent_of_material=Decimal(3) + ) + again = _detail_from_dict(_detail_to_dict(original)) + assert again.percent_of_material == Decimal(3) + # 제잡비(노무비의 %)도 같은 자리에서 빠져 있었다 — 함께 살린다. + labor_row = PriceDetail("B-1", "B-1", Decimal(0), percent_of_labor=Decimal(5)) + assert _detail_from_dict(_detail_to_dict(labor_row)).percent_of_labor == Decimal(5) + + +def test_칸을_저장했다_지웠다_할_수_있다(tmp_path, monkeypatch) -> None: + """화면 [적용] 이 지나는 길 — 넣기·되비우기·상한 거절 셋을 한자리에서 본다. + + ⚠ **되비우는 길이 있어야 한다** — 한번 넣으면 못 지우는 칸이면 「지금은 안 넣음」으로 + 돌아갈 수 없다. + """ + from B09_Estimation import B09_Estimation_Router as router + + project_id = uuid4() + monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path))) + + def _put(value: str): + body = router.FactorChoiceBody(misc_material_percent=value) + got = asyncio.run(router.put_factor_choices(project_id, body)) + return got.status_code, json.loads(got.body.decode()) + + from common_util.common_util_project_settings import estimation_settings + + status, _ = _put("3") + assert status == 200 + assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3" + + status, payload = _put("6") + assert status == 400 and "1-2-6" in payload["message"] + # ⚠ 거절된 값이 저장을 건드리면 안 된다 — 3% 가 그대로 살아 있어야 한다. + assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3" + + status, _ = _put("") + assert status == 200 + assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "" + + +def _fake_root(path: str): + async def _root(_project_id): + return path + + return _root