From b876ba9c146f0723495ced6e43109c3a80822445 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 00:01:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(b09):=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EC=B8=B5=20=EC=B0=A8=EB=A1=80=EB=A5=BC=20X=20=E2=86=92=20D=20?= =?UTF-8?q?=E2=86=92=20B=20=EB=A1=9C=20=E2=80=94=20=EA=B8=88=EC=95=A1=20?= =?UTF-8?q?=EB=B6=88=EB=B3=80,=20=EC=9E=90=EC=9B=90=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=20=EB=B2=84=EB=A6=BC=20=EA=B2=B0=ED=95=A8=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시공능력 Q 로 선 기계 줄(굴착기·도자·직접 작업량·다짐·암 잎)은 D(단가산출)에 달고 B 는 D 를 수량 1 로 부름 - 종전 PriceBasis 의 껍데기 D(D → B) 걷음 — 「단산 N」은 D 를 품은 줄에만(갈래 줄도 실제 단가 코드로 찾음) - 자원 집계가 B→B·B→D 참조를 끝까지 풀어 기계 사용료에 닿음(종전 조용히 버림) · 모르는 종류는 드러냄 - 조합 16% 바꿔 달기·공종 근거 문구가 D 줄도 봄 - 전후 대조: B·X 420 제목 금액 전부 같음 · 검증 프로젝트 내역 본체 122,924,846 그대로 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B09_Estimation/B09_Estimation_BasisSheet.py | 16 +- .../B09_Estimation_BillOfQuantities.py | 9 +- .../B09_Estimation_BillOfQuantities_Rows.py | 1 + B09_Estimation/B09_Estimation_Lists.py | 34 +++-- .../B09_Estimation_MachineProductivity.py | 27 ++-- ...09_Estimation_MachineProductivity_Dozer.py | 12 +- B09_Estimation/B09_Estimation_ParentSteps.py | 10 +- B09_Estimation/B09_Estimation_PriceBasis.py | 137 ++++++++---------- B09_Estimation/B09_Estimation_PriceBook.py | 24 +++ B09_Estimation/B09_Estimation_UnitPrice.py | 77 +++++----- .../tester/test_b09_fill_and_slope_price.py | 8 +- resources/tester/test_b09_parent_steps.py | 8 +- resources/tester/test_b09_price_layers.py | 77 ++++++++++ 13 files changed, 274 insertions(+), 166 deletions(-) create mode 100644 resources/tester/test_b09_price_layers.py diff --git a/B09_Estimation/B09_Estimation_BasisSheet.py b/B09_Estimation/B09_Estimation_BasisSheet.py index fe047296..fcb0419b 100644 --- a/B09_Estimation/B09_Estimation_BasisSheet.py +++ b/B09_Estimation/B09_Estimation_BasisSheet.py @@ -110,11 +110,19 @@ def work_item_basis(build: Any) -> list[dict[str, Any]]: for code, title in sorted(build.book.titles.items()): if title.kind is not PriceKind.UNIT_PRICE: continue - notes = [ - detail.note.strip() - for detail in build.book.details.get(code, []) - if detail.note and detail.note.strip() + own = build.book.details.get(code, []) + # Q 식 문구는 층 차례 뒤 D(단가산출) 줄에 삶 — 그 일위대가가 부르는 D 의 줄 문구도 함께(명세 3장). + basis = [ + detail + for ref in own + if ref.ref_code.startswith("D-") + for detail in build.book.details.get(ref.ref_code, []) ] + notes = [] + for detail in [*own, *basis]: + text = detail.note.strip() if detail.note else "" + if text and text not in notes: + notes.append(text) work_item_code = code[2:].split("#")[0] source = build.factor_sources.get(work_item_code) if source and source not in notes: diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index eaa0a4e9..6a6a70d9 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -163,6 +163,8 @@ class BillRow: expense_krw: Decimal = _ZERO is_group: bool = False in_bill: bool = True + #: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글. + price_code: str = "" #: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고, #: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮). #: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다** @@ -592,10 +594,11 @@ def build_bill( for row in result.rows: if row.is_group or row.code is None or row.amount_krw is None: continue - entry = sheet.by_unit_price(f"B-{row.code}") - if entry is not None: + # 그 줄이 **실제로 쓴** 단가 코드(갈래 `#…` 포함)로 찾음 — 갈래로 선 줄도 번호가 붙게. + label = sheet.label_for(row.price_code or f"B-{row.code}") + if label: # 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다. - row.notes.insert(0, ("unit_price_krw", entry.label)) + row.notes.insert(0, ("unit_price_krw", label)) result.price_basis = sheet if any(m.surcharge_pct is None for m in materials): diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index b61f2f95..11d00e31 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -440,6 +440,7 @@ def _leaf_row( # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) + row.price_code = price_code unit_money = unit_prices.book.resolve(price_code) line = unit_money.scaled(item.quantity) diff --git a/B09_Estimation/B09_Estimation_Lists.py b/B09_Estimation/B09_Estimation_Lists.py index a166d819..d2f067ae 100644 --- a/B09_Estimation/B09_Estimation_Lists.py +++ b/B09_Estimation/B09_Estimation_Lists.py @@ -149,22 +149,20 @@ def resource_summary( `quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양). 한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다. - ⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가 - 실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 - 이중으로 세는 것**이 된다. + ⚠ **자원(노무·자재·기계 사용료)까지 편다** — 일위대가 → 자원이 실무 집계표의 깊이다. + 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 이중으로 세는 것**이 된다. + ⚠ 가운데 층(단계 합산 부모의 잎 일위대가 `B→B` · 단가산출 `B→D`)은 **끝까지 풀어** 자원에 닿음. + 종전엔 한 겹만 펴 그런 참조를 **조용히 버렸음**(암절취 부모 · 층 차례 뒤 기계 몫, 2026-09-13). """ prices = build or cached_build() book = prices.book #: 자원코드 → [수량, 제목] picked: dict[str, list[Any]] = {} missing: list[str] = [] + #: 풀어 내려갈 가운데 층 — 자원이 아니라 자원을 품은 표. + unfold = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS) - 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)) + def walk(code: str, amount: Decimal, seen: tuple[str, ...]) -> None: for detail in book.details.get(code, []): if ( detail.percent_of_labor is not None @@ -173,11 +171,22 @@ def resource_summary( ): continue # 비율 줄은 자원이 아니다 — 목록표에 설 자재·노임이 없다 ref = detail.ref_code - if ref == code: + if ref == code or ref in seen: continue - slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)]) + title = book.titles.get(ref) + if title is not None and title.kind in unfold: + walk(ref, detail.quantity * amount, (*seen, ref)) + continue + slot = picked.setdefault(ref, [_ZERO, title]) slot[0] += detail.quantity * amount + 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 + walk(code, Decimal(str(quantity)), (code,)) + groups: dict[str, list[dict[str, Any]]] = { "labor": [], "material": [], @@ -195,6 +204,9 @@ def resource_summary( PriceKind.MACHINE_HOURLY: "machine", }.get(title.kind) if bucket is None: + # 일식(W)은 단가 0 이라 자원이 아님 — 그 밖의 종류가 오면 조용히 버리지 않고 드러냄. + if title.kind is not PriceKind.LUMPSUM: + missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})") continue try: unit_money = book.resolve(ref) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 530c9229..25f53017 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -358,8 +358,6 @@ def attach_machine_share( if node is None: return _ZERO - from B09_Estimation.B09_Estimation_PriceBook import PriceDetail - for table in node.get("tables", []): factors = extract_cycle_factors(work_item_code, table, choices, machines) if not isinstance(factors, CycleFactors): @@ -381,19 +379,18 @@ def attach_machine_share( if factors.machine_ratio_pct is None else Decimal(str(factors.machine_ratio_pct)) / Decimal(100) ) - book.add_detail( - PriceDetail( - title_code, - hourly_code, - machine_hours_per_unit(factors) * share, - # 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.** - note=factors.formula_text - + ( - f" · {(sources or {}).get(work_item_code, '')}" - if (sources or {}).get(work_item_code) - else "" - ), - ) + # Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). + book.add_output_detail( + title_code, + hourly_code, + machine_hours_per_unit(factors) * share, + # 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.** + note=factors.formula_text + + ( + f" · {(sources or {}).get(work_item_code, '')}" + if (sources or {}).get(work_item_code) + else "" + ), ) cycle_factors[work_item_code] = factors return share * Decimal(100) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py index 8ff27a9b..d951d0a8 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -399,8 +399,6 @@ def attach_dozer_share( 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 때만** 굴착기 쪽으로 간다. """ - from B09_Estimation.B09_Estimation_PriceBook import PriceDetail - node = next( (w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code), None, @@ -427,13 +425,9 @@ def attach_dozer_share( note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", ) continue - book.add_detail( - PriceDetail( - title_code, - hourly_code, - dozer_machine_hours_per_unit(factors), - note=factors.formula_text, - ) + # Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). + book.add_output_detail( + title_code, hourly_code, dozer_machine_hours_per_unit(factors), factors.formula_text ) return Decimal(100) return _ZERO diff --git a/B09_Estimation/B09_Estimation_ParentSteps.py b/B09_Estimation/B09_Estimation_ParentSteps.py index bb87f9a8..c0733970 100644 --- a/B09_Estimation/B09_Estimation_ParentSteps.py +++ b/B09_Estimation/B09_Estimation_ParentSteps.py @@ -123,13 +123,9 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None: ) ) for machine_code, _name in machines: - build.book.add_detail( - PriceDetail( - title_code, - f"X-{machine_code}", - Decimal(1) / capacity, - note=f"{note} · {source}", - ) + # Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). + build.book.add_output_detail( + title_code, f"X-{machine_code}", Decimal(1) / capacity, f"{note} · {source}" ) build.variants.setdefault(code, []).append(variant) # 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결). diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py index dc35bb2b..e2441423 100644 --- a/B09_Estimation/B09_Estimation_PriceBasis.py +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -1,11 +1,15 @@ -"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3). +"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3 · 6장 층 차례). -**무엇인가** — 내역서 한 줄의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는 -줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다 -(8-13 관측). STC 실측도 `D01341 절토(토사) 굴삭기0.7㎥ m³ 1,939` 처럼 **`D` 가 `B` -(일위대가)를 참조하는 한 층 위**였다. +**무엇인가** — 시공능력 **Q** 로 선 기계 몫의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는 +줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다(8-13 관측). - D 단가산출 → B 일위대가 → X 시간당 사용료 → S·M·L 카탈로그 + X 시간당 중기사용료 → D 단가산출(Q 식: 시간당 성분 ÷ Q) → B 일위대가 → 내역 + +⚠⚠ **층 차례 정정(2026-09-13 브레인 판정 · 명세 3장)** — 종전엔 D 가 **B 를 그대로 부르는 껍데기** + (D → B 수량 1)였고 B 가 X 를 바로 불렀음(뒤집힘). 이제 D 는 **Q 쓰는 자리에만** 서고 + (`PriceBook.add_output_detail`), B 가 D 를 수량 1 로 부름. 내역 코드 축은 B 하나(금액 같음). +⚠ 그래서 「단산 N」은 **D 를 품은 내역 줄에만** 붙음 — 사람 품만 있는 일위대가엔 없는 근거라 안 붙음 + (STmate 도 D 가 있는 줄에만). ⚠ **표를 세 벌 만들지 않는다** (PLAN 9-3). `PriceBook` 의 「제목 + 상세」 한 쌍에 `kind` 만 `PRICE_BASIS` 로 얹는다 — 일위대가와 같은 구조, 같은 화면 모양이다. @@ -20,16 +24,14 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any -from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle +from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build -_ONE = Decimal(1) - @dataclass class PriceBasisEntry: - """단가산출서 한 장 — 참조번호 + 그 줄이 무엇을 참조하는지.""" + """단가산출서 한 장 — 참조번호 + 그 산출을 부르는 일위대가.""" number: int code: str @@ -37,8 +39,10 @@ class PriceBasisEntry: spec: str unit: str unit_price_krw: Decimal - #: 이 산출서가 참조하는 일위대가 코드. 화면에서 눌러 내려가는 자리. + #: 이 산출서를 **처음 부른** 일위대가 코드 — 화면에서 눌러 내려가는 자리. ref_code: str + #: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함). + unit_price_codes: list[str] = field(default_factory=list) @property def label(self) -> str: @@ -65,58 +69,66 @@ class PriceBasisSheet: entries: list[PriceBasisEntry] = field(default_factory=list) def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None: - return next((entry for entry in self.entries if entry.ref_code == ref_code), None) + return next((e for e in self.entries if ref_code in e.unit_price_codes), None) + + def label_for(self, ref_code: str) -> str: + """그 일위대가가 품은 산출서 번호 전부 — 단계 합산 부모는 둘 이상일 수 있음(「단산 3·4」).""" + numbers = [str(e.number) for e in self.entries if ref_code in e.unit_price_codes] + return f"단산 {'·'.join(numbers)} 참조" if numbers else "" def as_dict(self) -> dict[str, Any]: return {"entries": [entry.as_dict() for entry in self.entries]} +def _bases_of(book: PriceBook, code: str, seen: tuple[str, ...] = ()) -> list[str]: + """일위대가가 품은 D — 단계 합산 부모(B → 잎 B)는 잎까지 내려가 찾음. 줄 차례 그대로.""" + found: list[str] = [] + for detail in book.details.get(code, []): + ref = detail.ref_code + title = book.titles.get(ref) + if title is None or ref == code or ref in seen: + continue + if title.kind is PriceKind.PRICE_BASIS: + found.append(ref) + elif title.kind is PriceKind.UNIT_PRICE: + found.extend(b for b in _bases_of(book, ref, (*seen, code)) if b not in found) + return found + + def build_price_basis( unit_price_codes: list[str], build: UnitPriceBuild | None = None, ) -> PriceBasisSheet: - """내역서에 쓰인 일위대가마다 산출서 한 장을 세운다. + """내역에 쓰인 일위대가가 품은 **단가산출(D)** 마다 한 장. 번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다. - 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다. + 같은 D 를 두 줄이 부르면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다. + ⚠ D 가 없는 일위대가(사람 품만)는 산출서가 없음 — 없는 근거를 가리키지 않음. """ prices = build or cached_build() + book = prices.book sheet = PriceBasisSheet() - seen: set[str] = set() - + numbered: dict[str, PriceBasisEntry] = {} for code in unit_price_codes: - if not code or code in seen or code not in prices.book.titles: + if not code or code not in book.titles: continue - seen.add(code) - title = prices.book.title(code) - money = prices.book.resolve(code) - basis_code = f"D-{code[2:]}" if code.startswith("B-") else f"D-{code}" - - if basis_code not in prices.book.titles: - prices.book.add_title( - PriceTitle( - code=basis_code, - kind=PriceKind.PRICE_BASIS, + for basis in _bases_of(book, code): + entry = numbered.get(basis) + if entry is None: + title = book.title(basis) + entry = PriceBasisEntry( + number=len(sheet.entries) + 1, + code=basis, name=title.name, spec=title.spec, unit=title.unit, + unit_price_krw=round_at(book.resolve(basis).total, OutputPlace.UNIT_PRICE_ROW), + ref_code=code, ) - ) - # ⚠ 지금은 **일위대가를 그대로 한 줄로** 참조한다. 할증·기타 비용이 붙는 - # 자리가 생기면 여기에 줄이 는다 — 구조를 미리 열어 둔다. - prices.book.add_detail(PriceDetail(basis_code, code, _ONE, note="일위대가 그대로")) - - sheet.entries.append( - PriceBasisEntry( - number=len(sheet.entries) + 1, - code=basis_code, - name=title.name, - spec=title.spec, - unit=title.unit, - unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW), - ref_code=code, - ) - ) + sheet.entries.append(entry) + numbered[basis] = entry + if code not in entry.unit_price_codes: + entry.unit_price_codes.append(code) return sheet @@ -124,42 +136,13 @@ def price_basis_detail( code: str, build: UnitPriceBuild | None = None, ) -> dict[str, Any]: - """산출서 한 장의 본표 — 무엇을 참조해 그 단가가 나왔는지. + """산출서 한 장의 본표 — Q 식 줄(시간당 중기사용료 × 1/Q)과 성분 합. - 일위대가 본표와 **같은 모양**이라 화면이 같은 표를 쓴다. + 일위대가 본표와 **같은 모양**(`detail_of`)이라 화면이 같은 표를 쓴다. """ from B09_Estimation.B09_Estimation_UnitPrice import detail_of prices = build or cached_build() - title = prices.book.title(code) - rows: list[dict[str, Any]] = [] - for detail in prices.book.details.get(code, []): - child = prices.book.title(detail.ref_code) - money = prices.book.resolve(detail.ref_code).scaled(detail.quantity) - rows.append( - { - "code": detail.ref_code, - "name": child.name, - "spec": child.spec, - "unit": child.unit, - "quantity": str(detail.quantity), - "total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), - "drillable": True, - "note": detail.note, - } - ) - - money = prices.book.resolve(code) - return { - "code": code, - "name": title.name, - "spec": title.spec, - "unit": title.unit, - "rows": rows, - "total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), - "material": str(money.material), - "labor": str(money.labor), - "expense": str(money.expense), - # 한 층 아래(일위대가) 본표를 그대로 딸려 보낸다 — 화면이 두 번 물어보지 않게. - "unit_price": detail_of(prices, rows[0]["code"]) if rows else None, - } + if prices.book.title(code).kind is not PriceKind.PRICE_BASIS: + raise LookupError(f"단가산출서 코드가 아닙니다: {code}") + return detail_of(prices, code) diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index 7bd8cf77..9e346f04 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -198,6 +198,30 @@ class PriceBook: def add_detail(self, detail: PriceDetail) -> None: self.details.setdefault(detail.parent_code, []).append(detail) + def add_output_detail( + self, parent_code: str, ref_code: str, quantity: Decimal, note: str = "" + ) -> str: + """**시공능력 Q** 로 선 장비 줄 — B 에 바로 안 달고 **D(단가산출)** 에 달고 B 는 D 를 1 로 부름. + + 층 차례 `X → D → B`(명세 3장 정정 · PLAN 6장 판정): D 는 Q 쓰는 자리에만 서고, 내역 코드 축은 + B 하나라 B → D 수량 1(금액 같음). 한 B 에 Q 줄이 여럿이면 **D 한 장**에 모임. D 코드를 돌려줌. + """ + parent = self.title(parent_code) + basis_code = f"D-{parent_code[2:]}" if parent_code.startswith("B-") else f"D-{parent_code}" + if basis_code not in self.titles: + self.add_title( + PriceTitle( + code=basis_code, + kind=PriceKind.PRICE_BASIS, + name=parent.name, + spec=parent.spec, + unit=parent.unit, + ) + ) + self.add_detail(PriceDetail(parent_code, basis_code, Decimal(1), note="단가산출(Q)")) + self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note)) + return basis_code + def title(self, code: str) -> PriceTitle: try: return self.titles[code] diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 16f68212..529565bd 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -223,26 +223,34 @@ def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> i """ swapped = 0 for title_code in work_item_titles: - details = book.details.get(title_code) or [] - if not any(detail.ref_code.startswith(_ATTACHMENT_PREFIXES) for detail in details): + # 장비 줄은 B 에 바로(표 시간) 또는 그 D(단가산출, Q) 에 있음 — 둘을 한 묶음으로 봄(층 차례). + own = book.details.get(title_code) or [] + group = [ + own, + *(book.details.get(d.ref_code) or [] for d in own if d.ref_code.startswith("D-")), + ] + if not any( + d.ref_code.startswith(_ATTACHMENT_PREFIXES) for details in group for d in details + ): continue - for index, detail in enumerate(details): - if not detail.ref_code.startswith("X-") or detail.ref_code.startswith( - _ATTACHMENT_PREFIXES - ): - continue - combined = f"{detail.ref_code}#조합" - if combined not in book.titles: - continue - details[index] = dataclass_replace( - detail, - ref_code=combined, - note=( - (detail.note + " · " if detail.note else "") - + "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)" - ), - ) - swapped += 1 + for details in group: + for index, detail in enumerate(details): + if not detail.ref_code.startswith("X-") or detail.ref_code.startswith( + _ATTACHMENT_PREFIXES + ): + continue + combined = f"{detail.ref_code}#조합" + if combined not in book.titles: + continue + details[index] = dataclass_replace( + detail, + ref_code=combined, + note=( + (detail.note + " · " if detail.note else "") + + "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)" + ), + ) + swapped += 1 return swapped @@ -953,22 +961,21 @@ def build_unit_prices( if capacity["ratio_pct"] is None else Decimal(str(capacity["ratio_pct"])) / Decimal(100) ) - build.book.add_detail( - PriceDetail( - title_code, - hourly_code, - (Decimal(1) / capacity["capacity_per_hour"]) * group_share, - note=( - f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" - f" (품셈 원문 표기 그대로)" - if not capacity.get("source_text") - # 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다. - else ( - f"작업량을 원문이 직접 줌 — {capacity['cell']}" - f" {capacity['capacity_per_hour']} · {capacity['source_text']}" - ) - ), - ) + # 작업량(Q)으로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). + build.book.add_output_detail( + title_code, + hourly_code, + (Decimal(1) / capacity["capacity_per_hour"]) * group_share, + note=( + f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" + f" (품셈 원문 표기 그대로)" + if not capacity.get("source_text") + # 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다. + else ( + f"작업량을 원문이 직접 줌 — {capacity['cell']}" + f" {capacity['capacity_per_hour']} · {capacity['source_text']}" + ) + ), ) attached_capacity = True diff --git a/resources/tester/test_b09_fill_and_slope_price.py b/resources/tester/test_b09_fill_and_slope_price.py index 0f1f05ba..be65e8a1 100644 --- a/resources/tester/test_b09_fill_and_slope_price.py +++ b/resources/tester/test_b09_fill_and_slope_price.py @@ -65,7 +65,11 @@ def test_비탈면다짐에_단가가_선다() -> None: def test_비탈면다짐은_원문_작업량을_그대로_쓴다() -> None: """② 77.7 은 원문 값이다 — 지어낸 수가 아님을 줄에 남긴다.""" build = _build() - rows = build.book.details["B-FP-09-17-01"] + # 층 차례 X → D → B(2026-09-13) — 작업량(Q) 줄은 D(단가산출)에, B 는 D 를 1 로 부름. + assert [(r.ref_code, r.quantity) for r in build.book.details["B-FP-09-17-01"]] == [ + ("D-FP-09-17-01", Decimal(1)) + ] + rows = build.book.details["D-FP-09-17-01"] assert rows, "상세 줄이 없습니다" for row in rows: # 시간 = 1 ÷ 77.7 ㎡/시간 @@ -76,7 +80,7 @@ def test_비탈면다짐은_원문_작업량을_그대로_쓴다() -> None: def test_부착_콤팩터_손료가_함께_붙는다() -> None: """④ 카탈로그에 **이름이 빈 줄**(0240-0007)이라 이름으로만 가리면 놓친다.""" build = _build() - refs = {row.ref_code for row in build.book.details["B-FP-09-17-01"]} + refs = {row.ref_code for row in build.book.details["D-FP-09-17-01"]} # 층 차례 — D 에 삶 assert "X-0240-0007" in refs, refs # 본체 굴착기는 조합 사용 층(잡재료 16%)으로 바뀌어야 한다 — 품셈 제8장 [주]⑤. assert any(ref.startswith("X-0201-0070#") for ref in refs), refs diff --git a/resources/tester/test_b09_parent_steps.py b/resources/tester/test_b09_parent_steps.py index b5140a2c..9521de47 100644 --- a/resources/tester/test_b09_parent_steps.py +++ b/resources/tester/test_b09_parent_steps.py @@ -41,7 +41,9 @@ def test_마스터_부모_모양은_기본_고르기_합산형만_단계를_든 def test_암절취는_암파쇄와_집토의_합으로_선다(): book = _build().book - leaf = book.details["B-FP-09-04-01#연암"] + # 층 차례 X → D → B — 잎 일위대가는 D(단가산출)를 1 로 부르고, 기계 1/Q 줄은 D 에 삶. + assert [d.ref_code for d in book.details["B-FP-09-04-01#연암"]] == ["D-FP-09-04-01#연암"] + leaf = book.details["D-FP-09-04-01#연암"] assert {d.ref_code for d in leaf} == {"X-0201-0070#조합", "X-0230-0007"} # 조합 16 % assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf) whole = book.resolve("B-FP-09-04#연암").total @@ -57,7 +59,7 @@ def test_단계가_못_서면_부모를_안_세우고_사유를_남긴다(): assert "FP-09-05-01" in build.component_gaps["FP-09-05"] # 발파 착암기 층 없음 assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 미구현 # 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음) - assert "[주]" in build.book.details["B-FP-09-05-02#경암"][0].note + assert "[주]" in build.book.details["D-FP-09-05-02#경암"][0].note # Q 줄은 D 에 def _bill(rock: str, method: str = "ripping"): @@ -96,7 +98,7 @@ def test_발파암은_단계_사유로_막히고_고르기형_부모는_오류() def test_깨기_브레이커는_굴착기_몸체_시간과_함께_붙는다(): """9-12·9-13 「깨기 대형브레이커」 — 부착 장비라 몸체도 같은 1/Q 로(2026-09-13 판정).""" - details = _build().book.details["B-FP-09-13-07"] + details = _build().book.details["D-FP-09-13-07"] # 작업량 줄은 D(단가산출)에 breaker = next(d for d in details if d.ref_code == "X-0230-0007") assert breaker.quantity == Decimal(1) / Decimal("3.5") * Decimal("0.9") # 장비 90 % 몫 breaking = [d for d in details if d.quantity == breaker.quantity] diff --git a/resources/tester/test_b09_price_layers.py b/resources/tester/test_b09_price_layers.py new file mode 100644 index 00000000..9974a4a4 --- /dev/null +++ b/resources/tester/test_b09_price_layers.py @@ -0,0 +1,77 @@ +"""단가 층 차례 `X → D → B` (2026-09-13, PLAN 6장 · 명세 3장 정정 · 브레인 판정). + +겨누는 것 + ① 시공능력 Q 로 선 기계 줄은 D(단가산출)에 살고 B 는 D 를 수량 1 로 부름 — D 는 X 만 부름 + ② 층 차례만 바꿈 — 금액 불변(420 제목 전후 대조는 스크립트, 여기선 B = D 합) + ③ 자원 집계가 가운데 층(B→B 잎 · B→D)을 **끝까지 풀어** 기계 사용료에 닿음(종전 조용히 버림) + ④ 「단산 N」은 D 를 품은 줄에만 — 사람 품만 있는 일위대가엔 산출서가 없음 +""" + +from __future__ import annotations + +from decimal import Decimal +from functools import lru_cache + +from B09_Estimation.B09_Estimation_Lists import resource_summary +from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis, price_basis_detail +from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices + + +@lru_cache(maxsize=1) +def _build(): + return build_unit_prices() + + +def test_D_는_X_만_부르고_B_가_D_를_수량_1로_부른다() -> None: + book = _build().book + bases = [code for code, t in book.titles.items() if t.kind is PriceKind.PRICE_BASIS] + assert len(bases) >= 40, len(bases) # 2026-09-13 기본 벌 45장 + for basis in bases: + refs = {book.titles[d.ref_code].kind for d in book.details[basis]} + assert refs == {PriceKind.MACHINE_HOURLY}, (basis, refs) + parent = f"B-{basis[2:]}" + calls = [d for d in book.details[parent] if d.ref_code == basis] + assert len(calls) == 1 and calls[0].quantity == Decimal(1), basis + # 금액은 B 에 D 한 줄이 그대로 — 사람 품 등 나머지 줄과 더해짐(층만 바뀜). + others = [d for d in book.details[parent] if d.ref_code != basis] + if not others: + assert book.resolve(parent) == book.resolve(basis) + # 굴착기 공식 공종 — Q 줄이 B 에 바로 안 붙음. + assert all( + book.titles[d.ref_code].kind is not PriceKind.MACHINE_HOURLY + for d in book.details["B-FP-09-03-02"] + ) + + +def test_자원_집계가_가운데_층을_끝까지_풀어_기계에_닿는다() -> None: + build = _build() + summary = resource_summary({"B-FP-09-04#연암": Decimal(1)}, build) + machines = {row["code"] for row in summary["groups"]["machine"]} + # 암절취 부모 → 잎 B(암파쇄·집토) → D → X — 종전엔 부모의 B→B 참조를 조용히 버렸음. + assert {"X-0230-0007", "X-0201-0070#조합"} <= machines, machines + assert not summary["missing"] + + +def test_단산_번호는_D_를_품은_줄에만_붙는다() -> None: + build = _build() + book = build.book + labor_only = next( + code + for code, t in sorted(book.titles.items()) + if t.kind is PriceKind.UNIT_PRICE + and book.details.get(code) + and all(book.titles[d.ref_code].kind is PriceKind.LABOR for d in book.details[code]) + ) + sheet = build_price_basis(["B-FP-09-03-02", labor_only, "B-FP-09-04#연암"], build) + assert [e.code for e in sheet.entries] == [ + "D-FP-09-03-02", + "D-FP-09-04-01#연암", + "D-FP-09-04-02", + ] + assert sheet.label_for("B-FP-09-03-02") == "단산 1 참조" + assert sheet.label_for(labor_only) == "" # 없는 근거를 가리키지 않음 + assert sheet.label_for("B-FP-09-04#연암") == "단산 2·3 참조" # 단계 합산 부모 + detail = price_basis_detail("D-FP-09-03-02", build) + assert detail["kind"] == PriceKind.PRICE_BASIS.value + assert detail["rows"] and all(r["ref_code"].startswith("X-") for r in detail["rows"]) From af23a5d209a99a68f53272c8eb1f9ef890fc70ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 00:26:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b09):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20Q=20=EC=8B=9D=20=E2=80=94=20f=C2=B7Q=20=EC=86=8C?= =?UTF-8?q?=EC=88=98=202=EC=9E=90=EB=A6=AC=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EB=92=A4=20=EB=82=98=EB=88=94=20=C2=B7=20D=20=EC=A4=84=200.1?= =?UTF-8?q?=EC=9B=90=C2=B7=EB=A8=B8=EB=A6=AC=20=EC=9B=90=20=EB=AF=B8?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=88=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix2(사사오입 0.01) — 굴착기·도자·다짐 작업량, 암 평균 Q, 표 직접 작업량 - TRUNCATED_KINDS 에 PRICE_BASIS(D) 더함 — B 는 다음 차례 - 골든셋 ③ 단가산출근거 실무 6건 208 호표 전수 일치(제외 소계·% 줄·산근 참조·계약단가 읽기) · 회귀 3,545.5 → 3,545 - 검증 프로젝트 내역 본체 122,924,846 → 122,870,975 · 전체 시험 1606 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B09_Estimation_MachineProductivity.py | 31 +++-- ...timation_MachineProductivity_Compaction.py | 18 ++- ...09_Estimation_MachineProductivity_Dozer.py | 10 +- B09_Estimation/B09_Estimation_ParentSteps.py | 8 +- B09_Estimation/B09_Estimation_PriceBook.py | 5 +- B09_Estimation/B09_Estimation_UnitPrice.py | 4 +- resources/tester/test_b09_golden_stmate.py | 119 +++++++++++++++++- 7 files changed, 164 insertions(+), 31 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 25f53017..9dd9335a 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -32,7 +32,7 @@ from __future__ import annotations import re from dataclasses import dataclass -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from typing import Any from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog @@ -75,6 +75,18 @@ class ProductivityError(ValueError): """시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다.""" +_HUNDREDTH = Decimal("0.01") + + +def fix2(value: Decimal) -> Decimal: + """**소수 2자리로 먼저 확정**(사사오입) — 작업량 Q 와 토량환산계수 f(명세 7장 · STmate 18번 §2.2). + + ⚠ 원값으로 나누면 어긋남 — `55,700 ÷ 15.708 = 3,545.96` ≠ `55,700 ÷ 15.71 = 3,545.5`. + f 도 `1/1.175 = 0.85` 로 먼저 자른 뒤 곱함(봉화 2024 단가산출근거 제2호표). + """ + return Decimal(value).quantize(_HUNDREDTH, rounding=ROUND_HALF_UP) + + def parse_measure(cell: str) -> Decimal | None: """계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.** @@ -117,7 +129,12 @@ class CycleFactors: def formula_text(self) -> str: return ( f"Q = 3600 ÷ {self.cycle_seconds} × {self.bucket_capacity_m3} × " - f"{self.bucket_coefficient} × {self.volume_factor} × {self.efficiency}" + f"{self.bucket_coefficient} × {fix2(self.volume_factor)} × {self.efficiency}" + + ( + f" = {hourly_output(self)} ㎥/hr (f·Q 소수 2자리 확정)" + if self.cycle_seconds > 0 + else "" + ) ) @@ -134,23 +151,23 @@ class FactorGap: def hourly_output(factors: CycleFactors) -> Decimal: """시간당 작업량 `Q` (㎥/hr). - `Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. + `Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. f 와 Q 는 **소수 2자리로 확정**(`fix2`). """ if factors.cycle_seconds <= 0: raise ProductivityError( f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다." ) - cycles_per_hour = _SECONDS_PER_HOUR / factors.cycle_seconds output = ( - cycles_per_hour + _SECONDS_PER_HOUR * factors.bucket_capacity_m3 * factors.bucket_coefficient - * factors.volume_factor + * fix2(factors.volume_factor) * factors.efficiency + / factors.cycle_seconds ) if output <= 0: raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") - return output + return fix2(output) def machine_hours_per_unit(factors: CycleFactors) -> Decimal: diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Compaction.py b/B09_Estimation/B09_Estimation_MachineProductivity_Compaction.py index f8d86641..47235a4a 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Compaction.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Compaction.py @@ -29,6 +29,8 @@ import re from decimal import Decimal from typing import Any +from B09_Estimation.B09_Estimation_MachineProductivity import fix2 + _ZERO = Decimal(0) #: 계수 이름 — 표 첫 칸이 「V(다짐속도,km/hr)」처럼 기호+설명이거나 「A」처럼 기호뿐이다. @@ -105,19 +107,13 @@ def capacity_per_hour(factors: dict[str, Decimal], formula: str = FORMULA_ROLLER """시간당 작업량(㎥/시간). **식이 둘**이라 어느 식인지 함께 받는다. 롤러 `Q = 1000 × V × W × E × D × f / N` · 콤펙터 `Q = A × N × H × f × E / P` + ⚠ f 와 Q 는 **소수 2자리로 확정**(명세 7장 — 원값으로 나누면 성분 단가가 어긋남). """ + f = fix2(factors["f"]) if formula == FORMULA_PLATE: - return ( - factors["A"] * factors["N"] * factors["H"] * factors["f"] * factors["E"] / factors["P"] - ) - return ( - Decimal(1000) - * factors["V"] - * factors["W"] - * factors["E"] - * factors["D"] - * factors["f"] - / factors["N"] + return fix2(factors["A"] * factors["N"] * factors["H"] * f * factors["E"] / factors["P"]) + return fix2( + Decimal(1000) * factors["V"] * factors["W"] * factors["E"] * factors["D"] * f / factors["N"] ) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py index d951d0a8..5c533fa7 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -30,6 +30,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity import ( ProductivityError, _first_measure, extract_cycle_factors, + fix2, parse_measure, ) @@ -112,12 +113,13 @@ class DozerFactors: def formula_text(self) -> str: return ( f"Q = 60 ÷ {self.cycle_minutes:.4f}분 × ({self.blade_capacity_m3} × " - f"{self.distance_factor}) × {self.volume_factor} × {self.efficiency}" + f"{self.distance_factor}) × {fix2(self.volume_factor)} × {self.efficiency}" + + (f" = {dozer_hourly_output(self)} ㎥/hr" if self.cycle_minutes > 0 else "") ) def dozer_hourly_output(factors: DozerFactors) -> Decimal: - """불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다.""" + """불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** — f 와 Q 는 소수 2자리로 확정(명세 7장).""" if factors.cycle_minutes <= 0: raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.") blade = factors.blade_capacity_m3 * factors.distance_factor @@ -125,12 +127,12 @@ def dozer_hourly_output(factors: DozerFactors) -> Decimal: _MINUTES_PER_HOUR / factors.cycle_minutes * blade - * factors.volume_factor + * fix2(factors.volume_factor) * factors.efficiency ) if output <= 0: raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") - return output + return fix2(output) def dozer_speeds( diff --git a/B09_Estimation/B09_Estimation_ParentSteps.py b/B09_Estimation/B09_Estimation_ParentSteps.py index c0733970..ab79c7df 100644 --- a/B09_Estimation/B09_Estimation_ParentSteps.py +++ b/B09_Estimation/B09_Estimation_ParentSteps.py @@ -20,6 +20,7 @@ import re from decimal import Decimal from typing import Any +from B09_Estimation.B09_Estimation_MachineProductivity import fix2 from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle ROCK_CLASSES = ("연암", "보통암", "경암") @@ -107,10 +108,9 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None: return variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows] if code in AVERAGE_BASIS: - average = sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)) - variants.append( - (AVERAGE_VARIANT, average, f"Q = {average:.4f} ㎥/hr — {AVERAGE_BASIS[code]}") - ) + # 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67. + average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows))) + variants.append((AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}")) for variant, capacity, note in variants: title_code = f"B-{code}#{variant}" build.book.add_title( diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index 9e346f04..5b41c6dd 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -60,8 +60,9 @@ CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHIN #: **호표 안에서 자르는** 층 — 줄 금액은 0.1원 미만, 성분 소계는 원 미만 절사(명세 7장). #: 근거 STmate 17번 — 중기사용료 호표 성분 소계 398/398 절사 #: (굴삭기 0.7㎥ 23,128 + 55,700 + 18,015 = 96,843 · 골든셋 실무 143 호표 전수). -#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **층 차례를 바로잡은 뒤** 붙임(PLAN 6장 판정). -TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY}) +#: D(단가산출)도 같음 — 줄 `중기 성분 ÷ Q` 0.1원 · 머리 성분 원 미만(봉화 2024 제2호표 3,545.5 → 3,545). +#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **아래층(D)부터 맞춘 뒤** 붙임(PLAN 6장 판정). +TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS}) _TENTH = Decimal("0.1") _WON = Decimal(1) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 529565bd..e0459e0d 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -36,6 +36,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity import ( FactorGap, attach_machine_share, extract_cycle_factors, + fix2, ) from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import ( attach_dozer_share, @@ -965,7 +966,8 @@ def build_unit_prices( build.book.add_output_detail( title_code, hourly_code, - (Decimal(1) / capacity["capacity_per_hour"]) * group_share, + # 작업량도 소수 2자리로 확정한 뒤 나눔(명세 7장) — 표·원문 값은 대개 이미 그 자리. + (Decimal(1) / fix2(capacity["capacity_per_hour"])) * group_share, note=( f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" f" (품셈 원문 표기 그대로)" diff --git a/resources/tester/test_b09_golden_stmate.py b/resources/tester/test_b09_golden_stmate.py index 6061e428..2698da2f 100644 --- a/resources/tester/test_b09_golden_stmate.py +++ b/resources/tester/test_b09_golden_stmate.py @@ -5,7 +5,7 @@ ② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ - ③ 단가산출 Q 식 `단가산출서` 시트 — 시간당 단가 ÷ Q (층 차례 뒤) + ③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅ ① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막) ⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). @@ -39,7 +39,7 @@ PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: """실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`.""" openpyxl = pytest.importorskip("openpyxl") - wanted = ("환율및기초자료", "중기사용료") + wanted = ("환율및기초자료", "중기사용료", "단가산출근거") found = [] for path in sorted(PRACTICE.rglob("*.xlsx")): if path.name.startswith("~$"): @@ -165,3 +165,118 @@ def test_중기_시간당_사용료_실무_원본_호표_전수_재현() -> None assert not misses, misses[:5] bonghwa = next(t for t in sheets if "현동" in t[0] and "0.7" in t[1]) assert _assemble(bonghwa[2]).total == Decimal(96843) + + +def _basis_blocks() -> list[tuple[str, str, tuple, list[tuple]]]: + """`단가산출근거` 시트의 호표 — `(원본, 호표, 머리 줄, Q 줄들)`. 머리 = 합계·노무·재료·경비. + + `소계(제외금액)`(틀 `(-=)`) 위 줄은 머리에 안 들어감 — Q 를 끌어내려고 보인 인력 품 따위. + """ + found = [] + for name, sheets in _workbooks(): + head: tuple | None = None + rows: list[tuple] = [] + kept = 0 # 마지막 소계까지 든 줄 수 + for row in [*sheets.get("단가산출근거", []), ("총계", None)]: + label = str(row[1] or "").replace(" ", "") + first = str(row[0] or "").replace(" ", "") + if (label.startswith("제") and label.endswith("호표")) or first.startswith("총"): + if head is not None and rows: + found.append((name, str(head[1]).replace(" ", ""), head, rows)) + head, rows, kept = (row if label.endswith("호표") else None), [], 0 + continue + if label.startswith("소계") or label.startswith("계("): + template = str(row[6]) + if "(-==)" in template: # 계(제외금액) — 그때까지 든 줄 전부 + rows, kept = [], 0 + elif "(-=)" in template: # 소계(제외금액) — 마지막 소계 뒤 줄 + rows = rows[:kept] + else: + kept = len(rows) + continue + if ( + head is not None + and first.startswith("계") # 「계」 · 「계(경비로적용)」 + and _num(row[2]) is not None + and _num(row[7]) is None # 「계약단가」·「계 x 낙찰율」 은 율 칸이 참 + ): + # 머리가 낙찰률을 곱한 계약단가인 원본 — 절사 규칙 대조는 그 앞 「계」 줄로. + head = (head[0], head[1], *row[2:]) + continue + if ( + head is not None + and len(row) > 8 + and _num(row[7]) is not None + and _num(row[8]) is not None + ): + rows.append(row) + return found + + +def _assemble_basis(rows: list[tuple]) -> Money3: + """원본 Q 줄(QTY · 성분 단가 J·K·L)을 **우리 D 층**에 올려 풂 — 줄 0.1원 · 머리 원 미만(엔진 규칙). + + 성분 단가 칸이 둘 이상 찬 줄은 다른 호표 참조(「산근 N호표」) — 성분마다 잎 하나씩. + """ + kinds = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE) + book = PriceBook() + book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출")) + for index, row in enumerate(rows): + quantity, price, shown = _num(row[7]), _num(row[8]), _num(row[2]) or Decimal(0) + if abs(quantity * price / 100 - shown) < abs(quantity * price - shown): + quantity /= 100 # 공구손료 「노무비 × 2 %」 — 칸 QTY 가 백분율 수인 줄 + parts = [(k, _num(p)) for k, p in zip(kinds, (*row[9:12], None, None, None))] + parts = [(k, p) for k, p in parts if p] + if not parts: # 성분 단가 칸이 빈 원본 — 줄 금액이 든 성분 칸으로 가름 + kind = kinds[next((i for i in range(3) if _num(row[3 + i])), 2)] + parts = [(kind, price)] + for kind, part in parts: + code = f"R{index}{kind.name}" + book.add_title(PriceTitle(code, kind, str(row[1]), slots=_slots(part))) + book.add_detail(PriceDetail("D", code, quantity)) + return book.resolve("D") + + +def test_단가산출_Q_식_실무_원본_호표_전수_재현() -> None: + """③ 줄 = 중기 성분 × 1/Q(Q 소수 2자리 확정) → 0.1원 미만 절사 · 머리 성분 = 원 미만 절사. + + 회귀 기준 — 봉화 2024 제2호표 굴삭기 0.2㎥ Q 15.71: 노무 55,700 ÷ Q = **3,545.5** → 머리 3,545(명세 7장). + """ + blocks = _basis_blocks() + if not blocks: + pytest.skip("실무 원본 XLSX 가 없음") + misses = [] + for name, title, head, rows in blocks: + got = _assemble_basis(rows) + want = (_num(head[2]), _num(head[3]), _num(head[4]), _num(head[5])) + if not want[1] and not want[2] and want[0] == want[3]: + # 경비로 넘기는 호표(운반·소운반 따위) — 성분 절사 합을 경비 한 칸에 모음. + got = Money3(expense=got.total) + if (got.total, got.labor, got.material, got.expense) != want: + misses.append((name, title, want, (got.total, got.labor, got.material, got.expense))) + assert len(blocks) >= 200, len(blocks) # 6건 208 호표 + assert not misses, (len(misses), misses[:5]) + second = next(b for b in blocks if "현동" in b[0] and b[1] == "제2호표") + assert Decimal("3545.5") in [_num(r[3]) for r in second[3]] + assert _assemble_basis(second[3]).labor == Decimal(3545) + + +def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None: + """③ 엔진 — 18번 §2.2 예: q 0.2 · f 1/1.175 → 0.85 · K 0.7 · Cm 15 · E 0.55 → Q **15.71**.""" + from B09_Estimation.B09_Estimation_MachineProductivity import ( + CycleFactors, + hourly_output, + machine_hours_per_unit, + ) + + factors = CycleFactors( + "T", "T", "0201-0020", "굴삭기", Decimal("0.2"), Decimal("0.7"), + Decimal(1) / Decimal("1.175"), Decimal("0.55"), Decimal(15), + ) # fmt: skip + assert hourly_output(factors) == Decimal("15.71") # 원값 15.708… + book = PriceBook() + book.add_title(PriceTitle("X", PriceKind.MACHINE_BASE, "중기", slots=_slots(Decimal(55700)))) + book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출")) + book.add_detail(PriceDetail("D", "X", machine_hours_per_unit(factors))) + # 줄 55,700 ÷ 15.71 = 3,545.5(0.1원 절사) → 머리 원 미만 3,545 · 원값 Q 로 나누면 3,545.9 로 갈림. + assert book.resolve("D").expense == Decimal(3545)