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:
@@ -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)"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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장 돌공사 — 재료 할증률표 없음"
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 조율 창 요청) ───────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user