feat(b08,b09): 사급 자재총괄 줄이 수동 단가로 내역 본체 「자재(사급)」 줄이 됨 · 기본 사급 · 이중계상 가드(코드 우선) · 규준틀 재료 이름/규격 가름(할증 각재 5·판재 10) · 할증 보류·이름 없음 사유 · 할증 「이내」 칸 서버

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 10:41:19 +09:00
co-authored by Claude Opus 5
parent 57a949c624
commit e6e6f9b7d5
18 changed files with 468 additions and 61 deletions
@@ -92,6 +92,10 @@ SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을
NOTE_RATE_MISSING = "할증률 미확보"
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트
#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도
#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정.
NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름"
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
@@ -109,6 +113,9 @@ class SurchargeTable:
effective_date: str = ""
source: dict[str, Any] = field(default_factory=dict)
rates: dict[str, dict[str, Any]] = field(default_factory=dict)
#: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄.
#: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림.
missing_reasons: dict[str, str] = field(default_factory=dict)
def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]:
"""(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**"""
@@ -138,10 +145,25 @@ def load_surcharge_table(path: Path | None = None) -> SurchargeTable:
for row in payload.get("rates_pct", [])
if row.get("material") is not None and row.get("rate") is not None
}
reasons = {
str(item.get("material")): (
f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}"
f" {item.get('rate')}% · {item.get('why_not_applied')}"
)
for item in (payload.get("candidates_pending_user") or {}).get("items") or []
if item.get("material")
}
reasons.update(
{
str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함"
for name in (payload.get("not_found") or {}).get("materials") or []
}
)
return SurchargeTable(
effective_date=str(payload.get("effective_date") or ""),
source=payload.get("source") or {},
rates=rates,
missing_reasons=reasons,
)
@@ -154,7 +176,10 @@ class MaterialRow:
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
net_amount: float = 0.0 # 순수량 — 할증 전
surcharge_pct: float | None = None # None = 미확보
surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장
supply: str = SUPPLY_UNKNOWN
#: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음.
supply_default: bool = False
install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다
surcharge_included: bool = False # 품셈에 이미 포함
basis: str = ""
@@ -187,6 +212,8 @@ class MaterialRow:
parts.append(self.basis)
if self.supply == SUPPLY_OWNER and self.install_by is None:
parts.append(NOTE_INSTALL_BY_MISSING)
if self.supply_default:
parts.append(NOTE_SUPPLY_DEFAULT)
return " · ".join(parts)
@@ -286,6 +313,19 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
return None
def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]:
"""(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지)."""
if raw is None or str(raw).strip() == "" or cap is None:
return cap, ""
try:
value = float(raw)
except (TypeError, ValueError):
return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀"
if not 0 <= value <= cap:
return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)"
return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)"
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)"
@@ -320,11 +360,16 @@ def build_table(
supply_map: dict[str, Any] | None = None,
extra_materials: Iterable[dict[str, Any]] = (),
concrete_placing_method: str | None = None,
surcharge_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양.
`extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다.
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
`surcharge_overrides` — `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1
「표의 값 **이내**」라 **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔 안 걺
(넘으면 안 쓰고 사유).
"""
table = surcharge_table or load_surcharge_table()
rows, skipped = _collect(unit_quantity_table)
@@ -341,9 +386,14 @@ def build_table(
missing_rate: list[str] = []
missing_supply: list[str] = []
missing_install_by: list[str] = []
chosen_rates = surcharge_overrides or {}
for row in rows.values():
name = row.name
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
setting = _supply_setting(supply, row)
row.supply, row.install_by = (
_supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None)
)
row.supply_default = setting is None
if row.supply == SUPPLY_UNKNOWN:
missing_supply.append(row.supply_key)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
@@ -353,8 +403,11 @@ def build_table(
continue
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
rate, basis = table.rate_for(lookup)
row.surcharge_cap = rate
rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key))
row.surcharge_pct = rate
row.basis = " · ".join(part for part in (basis, alias_note) if part)
reason = table.missing_reasons.get(lookup, "") if rate is None else ""
row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) if part)
if rate is None:
missing_rate.append(name)
@@ -379,6 +432,8 @@ def build_table(
"unit": row.unit,
"net_amount": row.net_amount,
"surcharge_pct": row.surcharge_pct,
# 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄).
"surcharge_cap": None if row.surcharge_included else row.surcharge_cap,
"total_amount": row.total_amount,
"supply": row.supply,
"supply_label": SUPPLY_LABELS.get(row.supply, row.supply),
@@ -523,6 +523,9 @@ FRAME_MATERIAL_SOURCE = (
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음
#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로.
FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")}
def frame_material_rows(
@@ -549,9 +552,11 @@ def frame_material_rows(
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, ""))
rows.append(
{
"name": name,
"name": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
@@ -432,6 +432,8 @@ class QuantitySettingsBody(BaseModel):
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
# 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름).
material_surcharge: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
@@ -574,6 +576,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
(
"rock_methods",
"material_supply",
"material_surcharge",
"concrete_placing_method",
"topsoil_target",
"tree_waste",
@@ -186,6 +186,7 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
)
@@ -439,6 +440,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
)
@@ -455,6 +457,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
@@ -428,12 +428,17 @@ def build_bill(
master: dict[str, Any] | None = None,
structure_prices: dict[str, dict[str, Any]] | None = None,
rates: dict[str, Any] | None = None,
material_prices: dict[str, Any] | None = None,
) -> BillResult:
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.
`structure_prices` — 구조물도 호표 금액 `{B-AX-ST-…#키: …}`(B08 `structure_bill_prices`,
**이 `build` 단가표로** 셈). 안 주면 호표 줄은 금액 없이 사유와 함께 섬.
`material_prices` — 「자재 단가」 탭 저장본. 사급 자재총괄 줄이 이 값으로 본체 「자재」 줄이 됨.
"""
from B09_Estimation.B09_Estimation_MaterialPrices import normalize as normalize_prices
manual_prices = normalize_prices(material_prices)
work_items, materials = parse_handoff(payload)
unit_prices = build or cached_build()
master = master or load_work_item_master()
@@ -576,7 +581,7 @@ def build_bill(
# ── 3) 자재 벌 ────────────────────────────────────────────────────────────
for material in materials:
result.material_rows.append(_material_row(material, result))
result.material_rows.append(_material_row(material, result, manual_prices))
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
@@ -649,7 +654,12 @@ def build_bill(
result.material_sheet = build_material_sheet(
materials,
surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"),
manual=manual_prices,
)
# 사급·수동 단가 자재 → 본체 「자재(사급)」 줄(이중계상 가드 · PLAN 1장 Ⓐ-2).
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import raise_material_rows
raise_material_rows(result, unit_prices, next_number, bill_line)
# ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13).
from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis
@@ -0,0 +1,120 @@
"""B09 원가계산 — 사급 자재총괄 줄을 **내역 본체 「자재」 줄**로 (PLAN 1장 Ⓐ-2 · 브레인 판정).
셈부터 함(배정 프로젝트 자재총괄 9줄): ㉠ 일위대가를 거쳐 이미 내역에 선 자재 0 · ㉡ 일위대가에 안
드는 자재 9 · ㉢ ㉠ 의 할증 0 — 품셈 표에 재료 줄이 없는 공종(규준틀·찰쌓기·타설)의 자재라 따로
계상하는 것이 실무 모양. 본체 줄로 서므로 도급 재료비·계약·기성·집계가 고칠 곳 없이 그 줄을 셈.
올리는 줄 사급 · 수동 단가가 선 줄만(미확정 1건) — 관급은 수량표에만(도급 금액 밖)
⚠ 이중계상 가드 — 같은 구조물 내역 줄의 일위대가 자재(붙은 것·못 붙은 것)와 겹치면 안 올리고
사유로 멈춤. **코드가 있으면 코드로, 없을 때만 이름으로**(명세 2장). 막는 쪽이라 틀려도 과소
(줄이 안 서고 사유가 뜸) — 양식이 늘어 ㉠ 이 생기는 날 금액이 조용히 부풀지 않게.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Callable
from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
MATERIAL_GROUP_NAME = "자재(사급)"
DOUBLE_COUNT_SUSPECT = "double_count_suspect"
_ZERO = Decimal(0)
def _flat(text: Any) -> str:
return "".join(str(text or "").split())
def unit_price_materials(row: BillRow, unit_prices: UnitPriceBuild) -> list[tuple[str, str]]:
"""그 내역 줄의 일위대가가 밟는 자재 — 붙은 것 (코드, 이름) · 못 붙은 줄 (「」, 글)."""
book = unit_prices.book
stack = [code for code in (row.price_code, *(code for code, _ in row.parts)) if code]
seen: set[str] = set()
found: list[tuple[str, str]] = []
while stack:
code = stack.pop()
if code in seen:
continue
seen.add(code)
title = book.titles.get(code)
if title is not None and title.kind is PriceKind.MATERIAL:
if not code.startswith(FUEL_CODE_PREFIX): # 기계 연료는 자재총괄 자재가 아님
found.append((code, title.name))
if code.startswith("B-"):
labels = unit_prices.unattached.get(code[2:].split("#", 1)[0]) or []
found.extend(("", label) for label in labels)
stack.extend(detail.ref_code for detail in book.details.get(code, []))
return found
def overlap(name: str, code: str, inside: list[tuple[str, str]]) -> str:
"""겹친 일위대가 자재 글 — 코드가 있으면 코드로, 없으면 이름(공백 무시 앞머리)으로."""
if code:
return next((label for found, label in inside if found == code), "")
wanted = _flat(name)
return next((label for _, label in inside if wanted and _flat(label).startswith(wanted)), "")
def raise_material_rows(
result: BillResult,
unit_prices: UnitPriceBuild,
next_number: Callable[[str], str],
bill_line: Callable[[Money3, Decimal], Money3],
) -> None:
"""자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만."""
sheet = result.material_sheet
picked = []
for item in getattr(sheet, "contractor_rows", []):
if item.amount_krw is None or not item.manual:
continue
inside = [
pair
for row in result.rows
if not row.is_group and row.name in item.source_structure
for pair in unit_price_materials(row, unit_prices)
]
hit = overlap(item.name, getattr(item, "code", ""), inside)
if hit:
reason = f"두 번 셀 수 있어 내역 줄로 안 올림 — 같은 구조물 일위대가에 「{hit}"
item.note = f"{item.note} · {reason}"
result.missing.append(
{
"name": item.key,
"unit": item.unit,
"quantity": str(item.total_amount),
"reason": reason,
"blocked_kind": DOUBLE_COUNT_SUSPECT,
}
)
continue
picked.append(item)
if not picked:
return
group_no = next_number("")
result.rows.append(
BillRow(item_no=group_no, level=1, code=None, name=MATERIAL_GROUP_NAME, is_group=True)
)
for index, item in enumerate(picked, start=1):
price = item.unit_price_krw
line = bill_line(Money3(material=price), item.total_amount)
row = BillRow(
item_no=f"{group_no}-{index}",
level=2,
code=None,
name=item.name,
spec=item.spec,
unit=item.unit,
quantity=item.total_amount,
)
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO
row.unit_price_krw = price
row.amount_krw = line.total
row.material_krw = line.material
row.unconfirmed = 1
row.add_note("quantity", f"자재총괄 할증 뒤 수량 — {', '.join(item.source_structure)}")
row.add_note("unit_price_krw", item.note)
result.unconfirmed.append({"name": row.name, "code": item.key, "count": 1})
result.rows.append(row)
@@ -596,8 +596,14 @@ def pending_formula_note(code: str | None) -> str:
return ""
def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**."""
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림
(금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
@@ -632,9 +638,14 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
+31 -14
View File
@@ -185,33 +185,50 @@ def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> i
return len(found)
def listing(build: Any, stored: Any) -> list[dict[str, Any]]:
"""화면 줄 — 자원 축이 쓰는 자재(코드) + 저장만 있고 자원 축에서 사라진 줄.
def listing(
build: Any, stored: Any, materials: list[dict[str, Any]] | None = None
) -> list[dict[str, Any]]:
"""화면 줄 — 자원 축이 쓰는 자재(코드) + 자재총괄 자재(「이름 규격」) + 저장만 남은 줄.
⚠ 사라진 줄도 조용히 안 버림 — 「단가표에서 사라진 코드」로 보이고 사용자가 지움.
`materials` — B08 인계 `materials`(자재총괄). 사급 줄만 단가 칸이 섬(관급은 도급 금액 밖).
⚠ 사라진 줄도 조용히 안 버림 — 「사라진 자재」로 보이고 사용자가 지움.
"""
prices = normalize(stored)
catalog = material_catalog_rows()
uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {}
rows: list[dict[str, Any]] = []
for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)):
info = catalog.get(code, {})
entry = prices.get(code) or {}
def add(key: str, shown: dict[str, Any], **extra: Any) -> None:
entry = prices.get(key) or {}
rows.append(
{
"key": code,
"name": info.get("name") or entry.get("name", ""),
"spec": info.get("spec") or entry.get("spec", ""),
"unit": info.get("unit") or entry.get("unit", ""),
"work_items": sorted(uses[code]),
"key": key,
**{
field: shown.get(field) or entry.get(field, "")
for field in ("name", "spec", "unit")
},
"price_krw": entry.get("price_krw"),
"source": entry.get("source", ""),
"entered_at": entry.get("entered_at", ""),
"missing": False,
**extra,
}
)
for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)):
add(code, catalog.get(code, {}), work_items=sorted(uses[code]), origin="unit_price")
for material in materials or []:
key = f"{material.get('material_name', '')} {material.get('spec') or ''}".strip()
add(
key,
{**material, "name": material.get("material_name")},
work_items=list(material.get("source_structure") or []),
origin="material_sheet",
supply_type=material.get("supply_type") or "",
quantity=str(material.get("total_amount")),
)
listed = {row["key"] for row in rows}
for key, entry in sorted(prices.items()):
if key in uses or not is_code_key(key):
continue
rows.append({"key": key, **entry, "work_items": [], "missing": True})
if key not in listed:
rows.append({"key": key, **entry, "work_items": [], "missing": True})
return rows
+33 -7
View File
@@ -58,6 +58,9 @@ class MaterialSheetRow:
surcharge_pct: Decimal | None = None
source_structure: tuple[str, ...] = ()
note: str = ""
#: 「자재 단가」 탭 키(이름 규격) · 수동 단가로 섰나(= 미확정).
key: str = ""
manual: bool = False
def as_dict(self) -> dict[str, Any]:
def money(value: Decimal | None) -> str | None:
@@ -75,6 +78,8 @@ class MaterialSheetRow:
"surcharge_pct": money(self.surcharge_pct),
"source_structure": list(self.source_structure),
"note": self.note,
"key": self.key,
"manual": self.manual,
}
@@ -119,8 +124,14 @@ def build_material_sheet(
*,
surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE,
catalog: MaterialCatalog | None = None,
manual: dict[str, dict[str, str]] | None = None,
) -> MaterialSheet:
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**."""
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.
`manual` — 「자재 단가」 탭 수동 단가(`MaterialPrices.normalize`, 키 「이름 규격」).
⚠ **사급 단가는 수동 단가만**(2026-09-14 PLAN 1장 Ⓐ-2) — 나라장터는 관급 단가라 사급에 쓰면
도급 재료비가 관급 값으로 조용히 섬. 관급 줄은 종전대로 나라장터(총원가 밖).
"""
book = catalog or load_material_catalog()
sheet = MaterialSheet()
@@ -141,6 +152,7 @@ def build_material_sheet(
surcharge_pct=getattr(material, "surcharge_pct", None),
source_structure=tuple(getattr(material, "source_structure", ()) or ()),
)
row.key = f"{row.name} {row.spec}".strip()
if row.supply_type == SUPPLY_UNKNOWN:
# ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다.
@@ -151,19 +163,33 @@ def build_material_sheet(
)
continue
found = book.resolve(row.name, row.spec)
if found is None:
entry = (manual or {}).get(row.key) if row.supply_type == SUPPLY_CONTRACTOR else None
found = book.resolve(row.name, row.spec) if row.supply_type == SUPPLY_OWNER else None
price = Decimal(entry["price_krw"]) if entry else found.price_krw if found else None
if price is None:
row.note = (
"자재 단가습니다 — 유료 물가지 미결(No.18). "
"6번 슬롯(적용 단가) 수동 입력 대기."
"사급 자재 단가 없음 — 「자재 단가」 탭에서 수동 입력(유료 물가지 미결 No.18)"
if row.supply_type == SUPPLY_CONTRACTOR
else "관급 자재 단가 없음 — 나라장터 목록에 이 품목이 없음"
)
sheet.missing.append(
{"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"}
)
else:
row.unit_price_krw = found.price_krw
row.unit_price_krw = price
# 자재대 줄도 **내역서 본체와 같은 절사** 자리다.
row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW)
row.amount_krw = round_at(price * row.total_amount, OutputPlace.BOQ_ROW)
if entry:
row.manual = True
row.note = " · ".join(
part
for part in (
"⚠ 수동 단가(미확정)",
entry.get("source"),
entry.get("entered_at"),
)
if part
)
if row.supply_type == SUPPLY_OWNER:
sheet.owner_rows.append(row)
+8 -3
View File
@@ -503,10 +503,15 @@ async def get_bill(project_id: UUID) -> JSONResponse:
from common_util.common_util_project_settings import estimation_settings
root = await _project_root_of(project_id)
rates = normalize((estimation_settings(root) if root else {}).get("edits")).get(
"bill_rates"
stored = estimation_settings(root) if root else {}
rates = normalize(stored.get("edits")).get("bill_rates")
result = build_bill(
payload,
build=build,
structure_prices=structure_prices,
rates=rates,
material_prices=stored.get("material_prices"),
)
result = build_bill(payload, build=build, structure_prices=structure_prices, rates=rates)
except DoubleCountError as error:
# 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다.
logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error)
@@ -42,16 +42,25 @@ class MaterialPriceRequest(BaseModel):
async def _rows(project_id: UUID) -> dict[str, Any]:
import json
from B08_Quantity.B08_Quantity_Router_Material import get_handoff
from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of
from common_util.common_util_project_settings import estimation_settings
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
build = await _build_for(project_id)
rows = listing(build, settings.get(MATERIAL_PRICES_KEY))
# 자재총괄 줄(「이름 규격」 키) — 인계를 못 받으면 코드 줄만 서고 그 사실을 `handoff_note` 로.
handoff = json.loads(bytes((await get_handoff(project_id)).body).decode("utf-8"))
materials = handoff.get("materials")
rows = listing(build, settings.get(MATERIAL_PRICES_KEY), materials)
return {
"status": "success",
"rows": rows,
"handoff_note": ""
if materials is not None
else f"B08 인계를 못 받아 자재총괄 줄이 빠짐 — {handoff.get('message') or ''}",
"unconfirmed_count": sum(1 for row in rows if row.get("price_krw")),
}
@@ -24,6 +24,10 @@ interface MaterialPriceRow {
source: string;
entered_at: string;
missing: boolean;
/** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */
origin?: string;
supply_type?: string;
quantity?: string;
}
interface MaterialPricesDto {
@@ -31,6 +35,23 @@ interface MaterialPricesDto {
message?: string;
rows: MaterialPriceRow[];
unconfirmed_count: number;
handoff_note?: string;
}
const SUPPLY_LABELS: Record<string, string> = {
contractor_supplied: "사급",
owner_supplied: "관급 — 도급 금액 밖(수량만)",
unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)",
};
function whereText(row: MaterialPriceRow): string {
if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움";
if (row.origin === "material_sheet") {
return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${
SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? ""
}`;
}
return `일위대가 · ${row.work_items.join(", ")}`;
}
interface Change {
@@ -75,11 +96,11 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v
}
const { wrap, tbody } = plainTable([
"코드",
"코드·키",
"명칭",
"규격",
"단위",
"쓰인 공종",
"쓰이는 곳",
"단가(원)",
"출처",
"넣은 날",
@@ -90,6 +111,13 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v
price.inputMode = "decimal";
price.title = row.price_krw ? won(row.price_krw) : "";
const source = input(row.source, "160px", "견적 업체·물가지 쪽");
// 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게).
const locked =
row.origin === "material_sheet" &&
row.supply_type !== "contractor_supplied" &&
!row.price_krw;
price.disabled = locked;
source.disabled = locked;
const priceCell = el("td", "b09s-num");
priceCell.append(price);
const sourceCell = el("td");
@@ -99,13 +127,7 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v
el("td", "", row.name),
el("td", "", row.spec),
el("td", "", row.unit),
el(
"td",
"b09s-note",
row.missing
? "⚠ 단가표에서 사라진 코드 — 단가를 비우고 저장해 지움"
: row.work_items.join(", "),
),
el("td", "b09s-note", whereText(row)),
priceCell,
sourceCell,
el("td", "", row.entered_at),
@@ -147,10 +169,14 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v
});
ctx.body.append(bar, wrap);
if (data.rows.length === 0) ctx.body.append(hint("일위대가가 쓰는 자재 코드가 없음"));
if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true));
if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음"));
ctx.body.append(
hint(
"수동 단가는 6번 슬롯(적용 단가)으로 서고, 닿은 내역 줄마다 「미확정」으로 셈 자재단가대비표에도 보임",
"일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임",
),
hint(
"자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖",
),
hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"),
);
@@ -6,8 +6,8 @@
"files": [
{
"file": "material_surcharge_2026-01-01.json",
"sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc",
"size_bytes": 4724
"sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f",
"size_bytes": 5087
}
]
}
@@ -149,18 +149,20 @@
{
"material": "막자갈",
"rate": 4,
"pumsem": "const",
"pumsem": "forest",
"listed_in": "산림청고시 제2025-82호 1-3-1 3호 「부순돌ㆍ자갈ㆍ막자갈 4」(건설품셈도 같은 조건)",
"listed_condition": "노상 및 노반재료(선택층·보조기층·기층)",
"our_usage": "돌쌓기 뒤채움",
"why_not_applied": "조노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음"
"why_not_applied": "조쓰임을 노반재료로 못박아 뒤채움에 그 상한을 끌어올 근거가 없음(2026-09-14 브레인 판정 보류 유지 · 사방기술교본도 할증률 없음)"
}
]
},
"not_found": {
"note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.",
"materials": ["야면석", "고임돌", "물구멍관"],
"materials": ["야면석", "고임돌", "물구멍관", "못"],
"checked": [
"산림품셈 1-3-1 전 19종",
"못 — 산림청고시 제2025-82호 1-3-1 6호 기타재료에 없음(2026-09-14 브레인 확인)",
"건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)",
"건설품셈 제7장 돌공사 — 재료 할증률표 없음"
]
+16 -1
View File
@@ -33,7 +33,22 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
def 이름별(rows: list[dict]) -> dict:
return {(row["source"], row["name"]): row for row in rows}
return {(row["source"], f"{row['name']} {row.get('spec', '')}".strip()): row for row in rows}
def test_이름과_규격을_가른다() -> None:
"""이름 칸에 규격을 섞으면 할증률표(각재 5 · 판재 10)와 안 맞음 — 관급구분 키는 그대로."""
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table
rows = frame_material_rows(규준틀)
assert {(row["name"], row["spec"]) for row in rows} == {
("각재", "50×50"),
("판재", "T12"),
("", ""),
}
table = build_table({"structures": []}, extra_materials=rows)
rates = {row["supply_key"]: row["surcharge_pct"] for row in table["rows"]}
assert rates == {"각재 50×50": 5, "판재 T12": 10, "": None}
def test_개소가_서면_재료도_선다() -> None:
+4 -3
View File
@@ -552,11 +552,12 @@ def test_자재가_없으면_not_applied() -> None:
assert handoff["surcharge_status"] == "not_applied"
def test_관급구분_미분류가_계약값임() -> None:
"""`unknown` 은 세 번째 값이다 — B09 는 이 줄을 관급에도 도급에도 안 넣는다."""
def test_관급구분_안_정하면_기본_사급() -> None:
"""2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함)."""
unit = build_unit_table([구조물()])
handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit))
assert all(row["supply_type"] == "unknown" for row in handoff["materials"])
assert handoff["materials"]
assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"])
# ── 운반 줄 (2026-09-07 조율 창 요청) ───────────────────────────────
+35 -8
View File
@@ -103,7 +103,7 @@ def test_품셈에_포함된_항목은_또_붙이지_않음() -> None:
table = build_table(원단위표(성분("모래", "", 10.0, surcharge_included=True)))
row = (table, "모래")
assert row["total_amount"] == pytest.approx(10.0)
assert row["note"] == NOTE_INCLUDED
assert row["note"].split(" · ")[0] == NOTE_INCLUDED # 뒤는 「기본 사급」
# ── destination 가르기 ──────────────────────────────────────────────
@@ -214,7 +214,7 @@ def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None:
table = build_table(원단위표(성분("낯선자재", "", 10.0)))
row = (table, "낯선자재")
assert row["surcharge_pct"] is None
assert row["note"] == NOTE_RATE_MISSING
assert row["note"].split(" · ")[0] == NOTE_RATE_MISSING # 뒤는 「기본 사급」
assert "낯선자재" in table["missing_rate_materials"]
# 값은 잃지 않는다 — 순수량 그대로 둔다.
assert row["total_amount"] == pytest.approx(10.0)
@@ -252,13 +252,40 @@ def test_합계는_반올림하지_않음() -> None:
# ── 관급/사급 ───────────────────────────────────────────────────────
def test_안_정한_자재는_미정으로_드러남() -> None:
"""⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼
읽힌다(2026-09-09 화면 확인). (`unknown`) 판정 그대로."""
def test_안_정한_자재는_기본_사급으로_서고_그_사실이_적힘() -> None:
"""2026-09-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느
합계에도 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 그대로 미정."""
table = build_table(원단위표(성분("야면석", "", 5.0)))
assert (table, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (table, "야면석")["supply_label"] == "미정(발주기관 결정)"
assert table["missing_supply_materials"] == ["야면석"]
assert (table, "야면석")["supply"] == "contractor_supplied"
assert "기본 사급" in (table, "야면석")["note"]
assert table["missing_supply_materials"] == []
picked = build_table(원단위표(성분("야면석", "", 5.0)), supply_map={"야면석": "unknown"})
assert (picked, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (picked, "야면석")["supply_label"] == "미정(발주기관 결정)"
assert picked["missing_supply_materials"] == ["야면석"]
def test_율이_없는_까닭이_줄에_뜸() -> None:
"""보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임."""
table = build_table(원단위표(성분("막자갈", "", 10.0), 성분("", "", 1.0)))
assert (table, "막자갈")["surcharge_pct"] is None
assert "조건이 다름 — 사용자 확정 후 옮길 것" in (table, "막자갈")["note"]
assert "할증률표에 이름 없음" in (table, "")["note"]
def test_할증률은_표값_이내에서만_고름() -> None:
"""1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀."""
fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}})
unit = 원단위표(성분("판재", "", 1.0), 성분("낯선자재", "", 1.0))
lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3})
assert (lower, "판재")["surcharge_pct"] == 6 and (lower, "판재")["surcharge_cap"] == 10
assert "표 상한 10% 이내" in (lower, "판재")["note"]
assert (lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀
over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12})
assert (
(over, "판재")["surcharge_pct"] == 10
and "이내가 아니라 안 씀" in (over, "판재")["note"]
)
def test_설정이_정한_구분을_따름() -> None:
+73 -1
View File
@@ -80,13 +80,85 @@ def test_내역_줄은_미확정으로_선다() -> None:
assert not plain.unconfirmed
def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict:
return {
"material_name": name,
"spec": spec,
"unit": "",
"net_amount": total,
"total_amount": total,
"supply_type": supply,
"source_structure": source,
}
def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None:
"""Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈."""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
payload = {
"work_items": [],
"materials": [
_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]),
_자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]),
_자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]),
],
}
prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}}
result = build_bill(payload, build=cached_build(), material_prices=prices)
group = next(r for r in result.rows if r.name == "자재(사급)")
rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")]
assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림
assert rows[0].material_krw == Decimal("174460") # 0.2684 × 650,000
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("174460")
assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing)
sheet = result.material_sheet
assert (
sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual
) # 관급엔 수동 단가 안 씀
plain = build_bill(payload, build=cached_build())
assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0
def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None:
"""이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소)."""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap
built = cached_build(material_prices=((STAKE, "1500", ""),))
unit = built.book.titles["B-FP-05-15"].unit
payload = {
"work_items": [
{"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10}
],
"materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])],
}
result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}})
assert not any(r.name == "자재(사급)" for r in result.rows)
hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect")
assert "말뚝" in hit["reason"]
inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")]
assert overlap("시멘트", "", inside).startswith("시멘트")
assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만
def test_목록은_자원_축_자재와_사라진_저장_줄() -> None:
built = cached_build()
stored = {
STAKE: {"price_krw": "1500"},
"AR-M-00000000": {"price_krw": "7", "name": "옛 자재"},
}
rows = {row["key"]: row for row in listing(built, stored)}
materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])]
rows = {
row["key"]: row for row in listing(built, {**stored, "": {"price_krw": "3"}}, materials)
}
assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500"
assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림
assert rows["AR-M-b0853497"]["price_krw"] is None
sheet_row = rows["각재 50×50"]
assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == (
"각재",
"50×50",
"material_sheet",
)
assert sheet_row["supply_type"] == "contractor_supplied" and rows[""]["missing"] is True