Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 00:27:40 +09:00
15 changed files with 437 additions and 196 deletions
+12 -4
View File
@@ -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)
+23 -11
View File
@@ -149,22 +149,20 @@ def resource_summary(
`quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양).
자원이 여러 공종에 걸리면 ** 줄로 합친다** 실무 시트가 모양이다.
**일위대가 안쪽을 겹만 편다.** 일위대가 자원(노무·자재·기계 사용료)까지가
실무 집계표의 깊이다. 기계 사용료(`X-`) 다시 손료·연료로 쪼개면 **중기 집계표와
이중으로 세는 ** 된다.
**자원(노무·자재·기계 사용료)까지 편다** 일위대가 자원 실무 집계표의 깊이다.
기계 사용료(`X-`) 다시 손료·연료로 쪼개면 **중기 집계표와 이중으로 세는 ** 된다.
가운데 (단계 합산 부모의 일위대가 `BB` · 단가산출 `BD`) **끝까지 풀어** 자원에 닿음.
종전엔 겹만 그런 참조를 **조용히 버렸음**(암절취 부모 · 차례 기계 , 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)
@@ -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:
@@ -358,8 +375,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 +396,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)
@@ -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"]
)
@@ -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(
@@ -399,8 +401,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 +427,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
+7 -11
View File
@@ -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(
@@ -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)
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결).
+60 -77
View File
@@ -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 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)
+27 -2
View File
@@ -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)
@@ -198,6 +199,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]
+44 -35
View File
@@ -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,
@@ -223,26 +224,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 +962,22 @@ 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,
# 작업량도 소수 2자리로 확정한 뒤 나눔(명세 7장) — 표·원문 값은 대개 이미 그 자리.
(Decimal(1) / fix2(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
+117 -2
View File
@@ -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)
+5 -3
View File
@@ -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]
+77
View File
@@ -0,0 +1,77 @@
"""단가 층 차례 `X → D → B` (2026-09-13, PLAN 6장 · 명세 3장 정정 · 브레인 판정).
겨누는
시공능력 Q 기계 줄은 D(단가산출) 살고 B D 수량 1 부름 D X 부름
차례만 바꿈 금액 불변(420 제목 전후 대조는 스크립트, 여기선 B = D )
자원 집계가 가운데 (BB · BD) **끝까지 풀어** 기계 사용료에 닿음(종전 조용히 버림)
단산 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"])