feat(B09): 자재대 표 신설 — 관급·사급·미정 셋으로 갈라 냄
PLAN 8-7 「자재대·관급자재대(금액)는 B09」 — B08 자재총괄이 낸 수량·할증에 단가를 붙여 금액을 내는 자리. 수량은 B08 것이 정본이라 다시 세지 않음 - **관급은 총원가 밖 별도 표기**(⑤ 관급자재대와 같은 값), 사급은 도급 재료비 - ⚠ `unknown`(관급·사급 미정)은 **어느 합계에도 안 넣음** — 넣으면 총액이 틀리고 어느 쪽으로 넣었는지 나중에 못 가림. 지금 실물 4건이 전부 이 상태 - 단가가 없으면 **금액을 안 만들고** 사유를 남김(사급 물가지 미결 No.18) - 할증률이 없으면 「할증 전 수량」임을 표에 적음 — 여기서 또 곱하지 않음(㉠) - 관급자재대 합계는 **천원 올림**(단수처리 규칙) - 화면 탭 신설 — 세 무리를 각각 표로 보이고 합계·사유를 함께 냄 검증: pytest 205 통과(신규 5 — 합계 분리·미정 제외·단가 없음·할증 깃발·천원 올림), tsc 0건. 화면 확인은 공용 브라우저 세션 만료로 다음 차례에 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,8 @@ class BillResult:
|
||||
notes: list[str] = field(default_factory=list)
|
||||
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
|
||||
price_basis: Any = None
|
||||
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
|
||||
material_sheet: Any = None
|
||||
|
||||
@property
|
||||
def direct_material_krw(self) -> Decimal:
|
||||
@@ -435,6 +437,14 @@ def build_bill(
|
||||
total_cut_volume_m3=cut_total,
|
||||
)
|
||||
|
||||
# 자재대 — B08 수량·할증에 단가를 붙인다. 관급은 총원가 밖 별도 표기다.
|
||||
from B09_Estimation.B09_Estimation_MaterialSheet import build_material_sheet
|
||||
|
||||
result.material_sheet = build_material_sheet(
|
||||
materials,
|
||||
surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"),
|
||||
)
|
||||
|
||||
# ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13).
|
||||
from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis
|
||||
|
||||
@@ -744,6 +754,7 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
|
||||
"group_rows": sum(1 for r in result.rows if r.is_group),
|
||||
"excluded_rows": len(result.excluded),
|
||||
"material_rows": len(result.material_rows),
|
||||
"material_sheet": result.material_sheet.as_dict() if result.material_sheet else None,
|
||||
"missing": result.missing,
|
||||
"body_total_krw": str(result.body_total_krw),
|
||||
"direct_material_krw": str(result.direct_material_krw),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」).
|
||||
|
||||
**무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다.
|
||||
수량은 B08 것이 정본이고 여기서 다시 세지 않는다.
|
||||
|
||||
**관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1)
|
||||
- **사급** — 도급 재료비. 내역서 안에 들어간다.
|
||||
- **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의
|
||||
「관급자재대」와 같은 값이라 그쪽과 이어야 한다.
|
||||
- **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간
|
||||
총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다.
|
||||
|
||||
⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을
|
||||
반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가
|
||||
`rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다.
|
||||
|
||||
⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은
|
||||
대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MaterialCatalog import (
|
||||
SUPPLY_CONTRACTOR,
|
||||
SUPPLY_OWNER,
|
||||
MaterialCatalog,
|
||||
load_material_catalog,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
|
||||
#: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다.
|
||||
SUPPLY_UNKNOWN = "unknown"
|
||||
|
||||
#: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08).
|
||||
SURCHARGE_APPLIED = "applied"
|
||||
SURCHARGE_NOT_APPLIED = "not_applied"
|
||||
SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaterialSheetRow:
|
||||
"""자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다."""
|
||||
|
||||
name: str
|
||||
spec: str
|
||||
unit: str
|
||||
net_amount: Decimal
|
||||
total_amount: Decimal
|
||||
supply_type: str
|
||||
unit_price_krw: Decimal | None = None
|
||||
amount_krw: Decimal | None = None
|
||||
surcharge_pct: Decimal | None = None
|
||||
source_structure: tuple[str, ...] = ()
|
||||
note: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
def money(value: Decimal | None) -> str | None:
|
||||
return None if value is None else str(value)
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"spec": self.spec,
|
||||
"unit": self.unit,
|
||||
"net_amount": str(self.net_amount),
|
||||
"total_amount": str(self.total_amount),
|
||||
"supply_type": self.supply_type,
|
||||
"unit_price_krw": money(self.unit_price_krw),
|
||||
"amount_krw": money(self.amount_krw),
|
||||
"surcharge_pct": money(self.surcharge_pct),
|
||||
"source_structure": list(self.source_structure),
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaterialSheet:
|
||||
"""자재대 한 벌 — 사급·관급·미정 셋으로 갈린다."""
|
||||
|
||||
contractor_rows: list[MaterialSheetRow] = field(default_factory=list)
|
||||
owner_rows: list[MaterialSheetRow] = field(default_factory=list)
|
||||
unknown_rows: list[MaterialSheetRow] = field(default_factory=list)
|
||||
#: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.**
|
||||
missing: list[dict[str, str]] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def contractor_total_krw(self) -> Decimal:
|
||||
"""사급 자재비 합계 — 도급 재료비로 들어간다."""
|
||||
return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO)
|
||||
|
||||
@property
|
||||
def owner_total_krw(self) -> Decimal:
|
||||
"""관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다."""
|
||||
return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"contractor": [row.as_dict() for row in self.contractor_rows],
|
||||
"owner": [row.as_dict() for row in self.owner_rows],
|
||||
"unknown": [row.as_dict() for row in self.unknown_rows],
|
||||
"contractor_total_krw": str(self.contractor_total_krw),
|
||||
# 관급자재대는 **천원 올림** 자리다(단수처리 규칙).
|
||||
"owner_total_krw": str(
|
||||
round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL)
|
||||
),
|
||||
"missing": self.missing,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
def build_material_sheet(
|
||||
materials: list,
|
||||
*,
|
||||
surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE,
|
||||
catalog: MaterialCatalog | None = None,
|
||||
) -> MaterialSheet:
|
||||
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**."""
|
||||
book = catalog or load_material_catalog()
|
||||
sheet = MaterialSheet()
|
||||
|
||||
if surcharge_status == SURCHARGE_RATE_UNAVAILABLE:
|
||||
sheet.notes.append(
|
||||
"할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 "
|
||||
"붙습니다 (PLAN 8-7 ㉠)."
|
||||
)
|
||||
|
||||
for material in materials:
|
||||
row = MaterialSheetRow(
|
||||
name=getattr(material, "material_name", ""),
|
||||
spec=getattr(material, "spec", ""),
|
||||
unit=getattr(material, "unit", ""),
|
||||
net_amount=getattr(material, "net_amount", _ZERO),
|
||||
total_amount=getattr(material, "total_amount", _ZERO),
|
||||
supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN),
|
||||
surcharge_pct=getattr(material, "surcharge_pct", None),
|
||||
source_structure=tuple(getattr(material, "source_structure", ()) or ()),
|
||||
)
|
||||
|
||||
if row.supply_type == SUPPLY_UNKNOWN:
|
||||
# ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다.
|
||||
row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다."
|
||||
sheet.unknown_rows.append(row)
|
||||
sheet.missing.append(
|
||||
{"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"}
|
||||
)
|
||||
continue
|
||||
|
||||
found = book.resolve(row.name, row.spec)
|
||||
if found is None:
|
||||
row.note = (
|
||||
"자재 단가가 없습니다 — 유료 물가지 미결(No.18). "
|
||||
"6번 슬롯(적용 단가) 수동 입력 대기."
|
||||
)
|
||||
sheet.missing.append(
|
||||
{"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"}
|
||||
)
|
||||
else:
|
||||
row.unit_price_krw = found.price_krw
|
||||
# 자재대 줄도 **내역서 본체와 같은 절사** 자리다.
|
||||
row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW)
|
||||
|
||||
if row.supply_type == SUPPLY_OWNER:
|
||||
sheet.owner_rows.append(row)
|
||||
elif row.supply_type == SUPPLY_CONTRACTOR:
|
||||
sheet.contractor_rows.append(row)
|
||||
else:
|
||||
sheet.unknown_rows.append(row)
|
||||
|
||||
if sheet.owner_rows:
|
||||
sheet.notes.append(
|
||||
"관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 "
|
||||
"관급자재대와 같은 값이어야 합니다."
|
||||
)
|
||||
return sheet
|
||||
@@ -552,7 +552,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
|
||||
["price_basis", "B09_Estimation_Tab_PriceBasis", true],
|
||||
["machine", "B09_Estimation_Tab_Machine", false],
|
||||
["duration", "B09_Estimation_Tab_Duration", false],
|
||||
["supply", "B09_Estimation_Tab_Supply", false],
|
||||
["supply", "B09_Estimation_Tab_Supply", true],
|
||||
["base_data", "B09_Estimation_Tab_BaseData", false],
|
||||
];
|
||||
|
||||
@@ -687,10 +687,31 @@ interface BillDto {
|
||||
blocked_kind?: string;
|
||||
}>;
|
||||
notes: string[];
|
||||
material_sheet: MaterialSheetDto | null;
|
||||
};
|
||||
price_basis: { entries: PriceBasisEntryDto[] };
|
||||
}
|
||||
|
||||
interface MaterialSheetRowDto {
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
total_amount: string;
|
||||
unit_price_krw: string | null;
|
||||
amount_krw: string | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface MaterialSheetDto {
|
||||
contractor: MaterialSheetRowDto[];
|
||||
owner: MaterialSheetRowDto[];
|
||||
unknown: MaterialSheetRowDto[];
|
||||
contractor_total_krw: string;
|
||||
owner_total_krw: string;
|
||||
missing: Array<{ name: string; reason: string }>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다.
|
||||
*
|
||||
@@ -987,6 +1008,63 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
body.append(split);
|
||||
};
|
||||
|
||||
/** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */
|
||||
const drawMaterialTab = (): void => {
|
||||
const sheet = bill?.summary.material_sheet ?? null;
|
||||
if (!sheet) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
empty.textContent = L("B09_Estimation_Mat_Empty");
|
||||
body.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [labelKey, rows, total] of [
|
||||
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw],
|
||||
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw],
|
||||
["B09_Estimation_Mat_Unknown", sheet.unknown, null],
|
||||
] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) {
|
||||
const head = document.createElement("div");
|
||||
head.className = "b09-hint";
|
||||
head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`);
|
||||
body.append(head);
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
const table = document.createElement("table");
|
||||
table.className = "b09-sheet";
|
||||
table.innerHTML =
|
||||
"<thead><tr><th>자재</th><th>규격</th><th>단위</th><th>수량</th>" +
|
||||
"<th>단가</th><th>금액</th><th>비고</th></tr></thead>";
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const text of [
|
||||
row.name,
|
||||
row.spec,
|
||||
row.unit,
|
||||
formatQuantity(row.total_amount),
|
||||
row.unit_price_krw ?? "",
|
||||
row.amount_krw ?? "",
|
||||
row.note,
|
||||
]) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = text;
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
table.append(tbody);
|
||||
body.append(table);
|
||||
}
|
||||
|
||||
for (const note of sheet.notes) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "b09-hint";
|
||||
line.textContent = note.replace(/\*\*/g, "");
|
||||
body.append(line);
|
||||
}
|
||||
};
|
||||
|
||||
const drawBody = (): void => {
|
||||
body.replaceChildren();
|
||||
if (activeTab === "unit_price") {
|
||||
@@ -1001,6 +1079,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
drawPriceBasisTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab === "supply") {
|
||||
drawMaterialTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "cost_sheet") {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
|
||||
@@ -677,6 +677,19 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."],
|
||||
B09_Estimation_PB_Ref: ["참조", "Refers to"],
|
||||
B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"],
|
||||
B09_Estimation_Mat_Owner: [
|
||||
"관급 자재 — 총원가 밖 별도 표기",
|
||||
"Owner-supplied — listed outside the total cost",
|
||||
],
|
||||
B09_Estimation_Mat_Unknown: [
|
||||
"관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다",
|
||||
"Supply type undecided — excluded from both totals",
|
||||
],
|
||||
B09_Estimation_Mat_Empty: [
|
||||
"설계내역서를 먼저 불러오면 자재대가 섭니다.",
|
||||
"Load the bill first and the material sheet appears.",
|
||||
],
|
||||
B09_Estimation_Boq_Precision: [
|
||||
"수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.",
|
||||
"Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.",
|
||||
|
||||
Reference in New Issue
Block a user