refactor(b09): 단가 층 차례를 X → D → B 로 — 금액 불변, 자원 집계 버림 결함 고침
- 시공능력 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user