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

This commit is contained in:
2026-09-14 00:58:35 +09:00
14 changed files with 531 additions and 13 deletions
@@ -48,6 +48,8 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
status = str(row.get("status") or "")
amount = row.get("amount")
ready = status == PREP_READY and amount is not None
# 값은 서도 **내역 줄이 아닌 줄**(경비 — 임목폐기물 처리). 막힘이 아니라 가는 곳이 다름.
billable = ready and row.get("bill") is not False
rows.append(
{
"work_item_code": row.get("work_item_code"),
@@ -88,8 +90,14 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
"spec_class_basis": str(row.get("reason") or ""),
"composite_not_ready": None,
# 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다.
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("reason") or status),
"in_bill": billable,
"in_bill_reason": (
""
if billable
else str(row.get("bill_reason") or "")
if ready
else str(row.get("reason") or status)
),
"origin": ORIGIN_PREPARATION,
}
)
@@ -56,6 +56,7 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_PENDING,
STATUS_READY,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
@@ -667,6 +668,9 @@ def build_table(
chipping_volume_m3: Any = None,
road_surface_area_m2: float | None = None,
topsoil_road_only: bool = False,
tree_waste: dict[str, Any] | None = None,
tree_waste_unit_price_krw_per_ton: Any = None,
waste_separate_order: Any = False,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = (
@@ -681,6 +685,10 @@ def build_table(
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
# 임목폐기물 — 톤 · 비목은 경비(내역 줄 아님 · 2026-09-14 판정).
+ tree_waste_rows(
slope_totals, tree_waste, tree_waste_unit_price_krw_per_ton, waste_separate_order
)
+ ancillary_rows(ancillary_counts)
)
return {
@@ -0,0 +1,150 @@
"""준비공 — 임목폐기물 처리 한 줄 (톤) · 2026-09-14 판정 「폐기물처리비」.
⭐ 수량 산식 — 한국건설기술연구원 「건설현장 임목폐기물의 재활용 및 자원화 기술개발」(2012) 3.2
지상부 WA = k × π × (B/2)² × h × (1+p) × W1 × N (kg)
뿌리부 WR = WA × 뿌리 분배비 ÷ 지상 분배비 = WA × 15/85 (일반 수목류, 같은 보고서 표)
k 수간 형상계수 0.5 · p 지엽 보합률(임목 0.3) — **산식 상수**라 칸이 없다.
B 평균 흉고직경 · h 평균 수고 · W1 수목 단위체적중량 · N 본수 —
**현장 조사값**이라 칸으로 받는다.
N = 표본지 1,000㎡ 실측 본수 × 대상 면적 ÷ 1,000 (실정보고 예 「134/1000 × 7,450」 모양).
대상 면적 = 벌목·지장목제거 면적(같은 나무를 벤 자리).
⚠ 금액은 **처리단가가 없어** 못 선다(KCRA 중간처리단가 미확보) — 수동 단가를 넣으면
금액이 서되 「미확정」으로 센다(빨간 테두리). 비목은 **경비**라 내역 줄이 아니고
원가계산서 폐기물처리비 줄로 간다(예정가격작성기준 제19조③18호).
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
TREE_WASTE_ITEM = "임목폐기물 처리"
STEM_FORM_FACTOR = 0.5 # k
FOLIAGE_ALLOWANCE = 0.3 # p — 임목(고립목은 1.0)
ROOT_SHARE, ABOVE_SHARE = 15.0, 85.0 # 일반 수목류 분배비
#: 칸 이름 · 화면 이름 · 단위 — 모두 채워야 수량이 선다.
TREE_WASTE_INPUTS: tuple[tuple[str, str], ...] = (
("stems_per_1000m2", "표본지 1,000㎡당 본수"),
("dbh_cm", "평균 흉고직경(㎝)"),
("height_m", "평균 수고(m)"),
("unit_weight_kg_m3", "단위체적중량(㎏/㎥)"),
)
TREE_WASTE_BASIS = (
"한국건설기술연구원(2012) 3.2 임목 발생량 — k 0.5 · p 0.3(임목)"
" · 뿌리 15 : 지상 85(일반 수목류)"
" · N = 표본지 1,000㎡ 본수 × 벌목·지장목제거 면적"
)
#: 톤 경계 — 화면이 알릴 뿐 값을 바꾸지 않는다.
REPORT_THRESHOLD_TON = 5.0
SEPARATE_ORDER_THRESHOLD_TON = 100.0
EXPENSE_NOTE = (
"비목은 경비(예정가격작성기준 제19조③18호) — 내역 줄이 아니라 원가계산서 폐기물처리비로 감"
)
def _positive(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def tree_waste_tons(area_m2: float, inputs: dict[str, Any]) -> tuple[float, float] | None:
"""(지상부 톤, 뿌리부 톤). 칸이 하나라도 비거나 면적이 0 이면 `None`."""
values = {key: _positive(inputs.get(key)) for key, _ in TREE_WASTE_INPUTS}
if area_m2 <= 0 or any(value is None for value in values.values()):
return None
stems = values["stems_per_1000m2"] * area_m2 / 1000.0
diameter = values["dbh_cm"] / 100.0
above_kg = (
STEM_FORM_FACTOR
* math.pi
* (diameter / 2.0) ** 2
* values["height_m"]
* (1.0 + FOLIAGE_ALLOWANCE)
* values["unit_weight_kg_m3"]
* stems
)
return above_kg / 1000.0, above_kg * ROOT_SHARE / ABOVE_SHARE / 1000.0
def tree_waste_rows(
slope_totals: dict[str, float] | None,
inputs: dict[str, Any] | None,
unit_price_krw_per_ton: Any = None,
separate_order: Any = False,
) -> list[dict[str, Any]]:
"""임목폐기물 처리 한 줄 — 값이 없어도 줄은 선다(무엇을 넣으면 풀리는지 적음)."""
slope = slope_totals or {}
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
inputs = inputs or {}
base = {
"group": "준비공",
"item": TREE_WASTE_ITEM,
"unit": "",
"work_item_code": None,
# 경비 — 내역에 세우지 않는다(인계가 이 칸을 본다).
"bill": False,
"bill_reason": EXPENSE_NOTE,
"separate_order": bool(separate_order),
}
tons = tree_waste_tons(area, inputs)
if tons is None:
missing = [label for key, label in TREE_WASTE_INPUTS if _positive(inputs.get(key)) is None]
why = (
f"현장 조사값이 비어 있음 — {' · '.join(missing)}"
if missing
else "벌목·지장목제거 면적이 0 ㎡ — 사면표가 아직 안 섬"
)
return [
{
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": f"{why}. 산식: {TREE_WASTE_BASIS}",
"reference_amount": area,
}
]
above, root = tons
total = above + root
notes = [
f"지상부 WA {above:,.2f}톤 + 뿌리부 WR {root:,.2f}톤(WA × 15/85)",
TREE_WASTE_BASIS,
EXPENSE_NOTE,
]
if total >= SEPARATE_ORDER_THRESHOLD_TON:
notes.append(
"⚠ 100톤 이상 — 건설폐기물이면 분리발주 대상(업무처리지침). 다만 임목폐기물은"
" 건설폐기물이 아닌 사업장 일반폐기물로 분류됨(같은 지침) — 산출 조건에서 고를 것"
)
elif total >= REPORT_THRESHOLD_TON:
notes.append("5톤 이상 — 배출자 신고 대상 사업장(폐기물관리법 시행령 제2조8호)")
price = _positive(unit_price_krw_per_ton)
row = {
**base,
"amount": total,
"status": STATUS_READY,
"reference_amount": area,
"above_ground_ton": above,
"root_ton": root,
"unit_price_krw_per_ton": price,
"amount_krw": math.floor(total * price) if price else None,
# 수동 단가로 선 금액 — 화면 「미확정 N건」·빨간 테두리.
"unconfirmed": 1 if price else 0,
}
if price:
notes.append(
f"처리비 {total:,.2f}× {price:,.0f}원 = {math.floor(total * price):,}"
" (⚠ 수동 단가 — 미확정 · KCRA 중간처리단가 미확보)"
)
else:
notes.append(
"처리단가 없음(KCRA 중간처리단가 미확보) — 산출 조건에 수동으로 넣으면 금액이 섬"
)
row["reason"] = " · ".join(notes)
return [row]
@@ -174,6 +174,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
road_surface_area(slopes),
# 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정).
topsoil_target(settings) == "road_only",
# 임목폐기물 — 현장 조사값 · 수동 처리단가 · 분리발주(2026-09-14 판정).
settings.get("tree_waste") or {},
settings.get("tree_waste_unit_price_krw_per_ton"),
settings.get("waste_separate_order"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -456,6 +460,10 @@ class QuantitySettingsBody(BaseModel):
wood_chipping_volume_m3: float | None = None
# 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄.
subgrade_compaction_enabled: bool | None = None
# 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주.
tree_waste: dict[str, float | None] | None = None
tree_waste_unit_price_krw_per_ton: float | None = None
waste_separate_order: bool | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
@@ -474,6 +482,7 @@ NULLABLE_SETTING_KEYS = (
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
"tree_waste_unit_price_krw_per_ton",
)
@@ -515,6 +524,13 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "tree_waste" in values:
# 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다.
values["tree_waste"] = {
key: float(value)
for key, value in (values["tree_waste"] or {}).items()
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
}
if "topsoil_target" in values:
# 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다.
target = values["topsoil_target"]
@@ -554,6 +570,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
"material_supply",
"concrete_placing_method",
"topsoil_target",
"tree_waste",
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
@@ -108,6 +108,12 @@ export interface QuantitySettings {
wood_chipping_volume_m3?: number | null;
/** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */
subgrade_compaction_enabled?: boolean | null;
/** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */
tree_waste?: Record<string, number | null>;
/** 처리단가(원/톤) — 수동 · 미확정. */
tree_waste_unit_price_krw_per_ton?: number | null;
/** 폐기물 분리발주 — 기본 아님. */
waste_separate_order?: boolean | null;
}
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
+16
View File
@@ -40,6 +40,7 @@ import {
} from "./B08_Quantity_UI_MaterialGrid";
import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
@@ -109,6 +110,10 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
subgrade_compaction_enabled: draft.subgrade_compaction_enabled,
// 조사값은 **통째로** — 지운 칸까지 가야 되돌릴 길이 있다.
tree_waste: draft.tree_waste,
tree_waste_unit_price_krw_per_ton: draft.tree_waste_unit_price_krw_per_ton,
waste_separate_order: draft.waste_separate_order,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
@@ -300,6 +305,10 @@ interface DraftSettings {
wood_chipping_volume_m3: number | null;
// 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량).
subgrade_compaction_enabled: boolean;
// 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님).
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
@@ -703,6 +712,9 @@ function buildQuantitySidePanel(
);
panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint")));
// ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서.
appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -1001,6 +1013,10 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled),
tree_waste: { ...((stored.tree_waste ?? {}) as Record<string, number | null>) },
tree_waste_unit_price_krw_per_ton:
(stored.tree_waste_unit_price_krw_per_ton as number | null) ?? null,
waste_separate_order: Boolean(stored.waste_separate_order),
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
@@ -0,0 +1,101 @@
/* =============================================================================
* B08_Quantity_UI_Side_TreeWaste.ts
* · · (2026-09-14 ).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* [] (5).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface TreeWasteDraft {
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
dirty: boolean;
}
/** 페이지의 칸 만들기 도우미 — 같은 모양을 쓰려고 받아 씀. */
export interface SideFieldHelpers {
field(label: string, value: string): HTMLElement;
optionalNumberField(
label: string,
value: number | null,
step: string,
onInput: (value: number | null) => void,
): HTMLElement;
selectField(
label: string,
value: string,
options: { value: string; label: string }[],
onChange: (value: string) => void,
): HTMLElement;
hintRow(text: string): HTMLElement;
}
/** 조사값 칸 — 서버 `TREE_WASTE_INPUTS` 와 같은 키. */
const INPUTS: [string, keyof typeof ui_locales, string][] = [
["stems_per_1000m2", "B08_Quantity_TreeWaste_Stems", "1"],
["dbh_cm", "B08_Quantity_TreeWaste_Dbh", "0.1"],
["height_m", "B08_Quantity_TreeWaste_Height", "0.1"],
["unit_weight_kg_m3", "B08_Quantity_TreeWaste_UnitWeight", "10"],
];
/** 수동 단가 칸 — 값이 있으면 빨간 테두리(미확정). */
function markManual(row: HTMLElement, value: number | null): void {
const input = row.querySelector("input");
if (input) input.style.outline = value ? "2px solid var(--color-danger, #d9534f)" : "";
}
export function appendTreeWasteFields(
panel: HTMLElement,
draft: TreeWasteDraft,
h: SideFieldHelpers,
): void {
panel.append(h.field(L("B08_Quantity_Side_TreeWaste"), ""));
for (const [key, label, step] of INPUTS) {
panel.append(
h.optionalNumberField(L(label), draft.tree_waste[key] ?? null, step, (value) => {
draft.tree_waste[key] = value;
draft.dirty = true;
}),
);
}
panel.append(h.hintRow(L("B08_Quantity_Side_TreeWaste_Hint")));
const price = h.optionalNumberField(
L("B08_Quantity_TreeWaste_Price"),
draft.tree_waste_unit_price_krw_per_ton,
"1000",
(value) => {
draft.tree_waste_unit_price_krw_per_ton = value;
draft.dirty = true;
markManual(price, value);
},
);
markManual(price, draft.tree_waste_unit_price_krw_per_ton);
panel.append(price);
const priceHint = h.hintRow(L("B08_Quantity_TreeWaste_Price_Hint"));
priceHint.classList.add("b08-quantity__hint--warn");
panel.append(priceHint);
panel.append(
h.selectField(
L("B08_Quantity_TreeWaste_Separate"),
draft.waste_separate_order ? "on" : "",
[
{ value: "", label: L("B08_Quantity_TreeWaste_Separate_Off") },
{ value: "on", label: L("B08_Quantity_TreeWaste_Separate_On") },
],
(value) => {
draft.waste_separate_order = value === "on";
draft.dirty = true;
},
),
);
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Separate_Hint")));
}
@@ -236,6 +236,8 @@ export interface PreparationRow {
reason: string;
reference_amount?: number;
work_item_code: string | null;
/** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */
unconfirmed?: number;
}
export interface PreparationTable {
@@ -260,6 +262,14 @@ export function renderPreparationGrid(
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) {
const badge = document.createElement("span");
badge.style.cssText =
"margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px";
badge.textContent = `미확정 ${unconfirmed}`;
caption.append(badge);
}
wrap.append(caption);
const scroller = document.createElement("div");
@@ -291,6 +301,8 @@ export function renderPreparationGrid(
if (row.reference_amount) {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
// 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정).
if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)";
tr.append(note);
// 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다.
markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined);
+22 -11
View File
@@ -88,10 +88,14 @@ class CostInput:
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히.
#: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」).
#: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐.
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
waste_disposal_krw: Decimal = _ZERO
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
waste_separate_order: bool = False
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
@@ -353,12 +357,14 @@ def _calculate_with_scale(
scale_reference=scale,
)
statutory = statutory_expenses(dataset, data, ctx, result, emit)
# 폐기물처리비 — 분리발주가 아니면 경비 한 줄. 법정경비 **뒤**에 둬 그 밑수에 안 섞인다.
waste_in_expense = _ZERO if data.waste_separate_order else _waste_line(data, emit)
expense_total = data.direct_expense_krw + statutory
expense_total = data.direct_expense_krw + statutory + waste_in_expense
emit(
key="expense",
name="경비",
base_label="직접경비(산출경비)+법정경비",
base_label="직접경비(산출경비)+법정경비" + ("+폐기물처리비" if waste_in_expense else ""),
base=expense_total,
amount=expense_total,
)
@@ -413,13 +419,14 @@ def _calculate_with_scale(
)
owner_total = _owner_supplied_line(data, emit)
waste = _waste_line(data, emit)
# 분리발주면 공사 원가가 아니다 — 총원가 밖에 서고 총공사비에만 더한다.
waste = _waste_line(data, emit) if data.waste_separate_order else _ZERO
grand_total = contract_amount + owner_total + waste
emit(
key="grand_total",
name="총공사비",
base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""),
base_label="도급공사비+관급자재대" + ("+폐기물처리비(분리발주)" if waste else ""),
base=grand_total,
amount=grand_total,
)
@@ -436,7 +443,7 @@ def _calculate_with_scale(
"vat": vat,
"contract_amount": contract_amount,
"owner_supplied_material_total": owner_total,
"waste_disposal": waste,
"waste_disposal": waste_in_expense + waste,
"grand_total": grand_total,
}
return result
@@ -508,16 +515,20 @@ def _owner_supplied_line(data: CostInput, emit) -> Decimal:
def _waste_line(data: CostInput, emit) -> Decimal:
"""폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다."""
"""폐기물처리비 — 요율이 아니라 실비. 금액이 있을 때만 줄이 선다."""
if not data.waste_disposal_krw:
return _ZERO
return emit(
key="waste_disposal",
name="폐기물처리비",
base_label="설계자 입력(실비)",
base_label="수량 × 처리단가(실비)",
base=data.waste_disposal_krw,
amount=floor_won(data.waste_disposal_krw),
note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)",
note=(
"분리발주 — 총원가 밖 별도 표기"
if data.waste_separate_order
else "경비(예정가격작성기준 제19조③18호) — 일반관리비·이윤 밑수에 듦"
),
)
+3
View File
@@ -82,6 +82,8 @@ class CostRequest(BaseModel):
estimated_price_krw: Decimal | None = None
profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0)
waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0)
#: 폐기물 분리발주 — 기본 아님(경비로 원가에 듦). 켜면 총원가 밖.
waste_separate_order: bool = False
environment_work_type: str = "civil_road"
equipment_guarantee_work_type: str = "civil_general"
@@ -116,6 +118,7 @@ class CostRequest(BaseModel):
estimated_price_krw=self.estimated_price_krw,
profit_adjustment_krw=self.profit_adjustment_krw,
waste_disposal_krw=self.waste_disposal_krw,
waste_separate_order=self.waste_separate_order,
environment_work_type=self.environment_work_type,
equipment_guarantee_work_type=self.equipment_guarantee_work_type,
subcontract_guarantee_variant=self.subcontract_guarantee_variant,
@@ -160,6 +160,13 @@ def default_settings() -> dict[str, Any]:
# 노체다짐(9-16-2) — **기본 꺼짐**(2026-09-13 판정). [주]⑤ 「대규모 성토지로서
# 층다짐이 필요한 경우 적용」 조건부라 늘 서지 않는다. 켜면 토공집계에 별도 줄.
"subgrade_compaction_enabled": False,
# 임목폐기물 — 현장 조사값 넷 `{stems_per_1000m2, dbh_cm, height_m, unit_weight_kg_m3}`.
# ⚠ 기본값을 두지 않는다 — 비면 톤이 안 선다(수종·임분마다 달라 지어내지 않음).
"tree_waste": {},
# 처리단가(원/톤) — KCRA 중간처리단가 미확보라 **수동**. 넣으면 금액이 서되 미확정.
"tree_waste_unit_price_krw_per_ton": None,
# 폐기물 분리발주 — 기본 아님(경비로 원가에 듦 · 2026-09-14 판정).
"waste_separate_order": False,
"dataset_versions": {},
},
"estimation": {
+66
View File
@@ -0,0 +1,66 @@
"""임목폐기물 처리 — 톤으로 서는가 · 경비라 내역에 안 서는가 (2026-09-14 판정).
산식 한국건설기술연구원(2012) 3.2: WA = 0.5 × π × (B/2)² × h × 1.3 × W1 × N · WR = WA × 15/85.
실정보고 (p26) V1 = 0.5×π×(0.16/2)²×10.0×1.3×134/1000×7,450 = 130.40
지상부 부피를 .
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import ( # noqa: E402
tree_waste_rows,
tree_waste_tons,
)
SLOPE = {"tree_removal_fill": 4000.0, "tree_removal_cut": 3450.0} # 7,450㎡
FIELD = {"stems_per_1000m2": 134, "dbh_cm": 16, "height_m": 10.0, "unit_weight_kg_m3": 1000}
def test_지상부는_실정보고_부피와_같다() -> None:
"""W1 = 1,000㎏/㎥ 로 두면 지상부 톤 = 지상부 부피(㎥) — 실정보고 130.40㎥."""
above, root = tree_waste_tons(7450.0, FIELD)
# 실정보고는 π 를 3.14 로 셈 — 같은 π 로 되돌리면 130.40 과 같다.
assert above * 3.14 / math.pi == pytest.approx(130.40, abs=0.01)
assert root == pytest.approx(above * 15 / 85)
def test_칸이_비면_톤이_안_서고_무엇이_빈지_적는다() -> None:
row = tree_waste_rows(SLOPE, {**FIELD, "height_m": None})[0]
assert row["amount"] is None and "평균 수고" in row["reason"]
assert row["reference_amount"] == 7450.0
def test_단가가_없으면_금액이_안_서고_있으면_미확정() -> None:
no_price = tree_waste_rows(SLOPE, FIELD)[0]
assert no_price["unit"] == "" and no_price["amount"] > 0
assert no_price["amount_krw"] is None and no_price["unconfirmed"] == 0
priced = tree_waste_rows(SLOPE, FIELD, 60000)[0]
assert priced["unconfirmed"] == 1
assert priced["amount_krw"] == int(priced["amount"] * 60000)
def test_톤_경계를_알린다() -> None:
big = tree_waste_rows(SLOPE, FIELD)[0] # 약 153톤
assert big["amount"] >= 100 and "100톤 이상" in big["reason"]
small = tree_waste_rows({"tree_removal_fill": 500.0}, FIELD)[0] # 약 10톤
assert 5 <= small["amount"] < 100 and "5톤 이상" in small["reason"]
def test_경비라_내역에_안_서고_제외_사유가_간다() -> None:
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
table = build_table(SLOPE, tree_waste=FIELD, tree_waste_unit_price_krw_per_ton=60000)
handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]}
row = handed["임목폐기물 처리"]
assert row["in_bill"] is False and row["blocked_kind"] is None
assert "경비" in row["in_bill_reason"]
@@ -0,0 +1,92 @@
"""폐기물처리비 자리 — 경비 비목 · 일반관리비·이윤 밑수에 듦 (2026-09-14 판정).
겨누는 것은 **구조** 내역 금액을 박지 않는다(다른 절사로 움직여도 깨지게).
분리발주 아님(기본): 경비 순공사원가·일반관리비 밑수·이윤 밑수가 **그만큼** 는다
법정경비 밑수에는 섞인다 법정경비 줄은 그대로
분리발주: 총원가 · 총공사비에만 더한다
근거 예정가격작성기준 제19조③18호(경비 세비목 폐기물처리비).
"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402
WASTE = Decimal(1_000_000)
#: 규모 구간을 못 박는다 — 폐기물 몫으로 구간이 바뀌면 율이 달라져 구조를 못 잰다.
BASE = dict(
direct_material_krw=Decimal(30_000_000),
direct_labor_krw=Decimal(40_000_000),
direct_expense_krw=Decimal(10_000_000),
estimated_price_krw=Decimal(100_000_000),
)
#: 폐기물이 들면 움직여야 하는 줄 — 나머지(법정경비 등)는 한 푼도 안 움직여야 함.
MOVES = {
"waste_disposal",
"expense",
"net_construction_cost",
"general_overhead",
"profit_before_adjustment",
"profit",
"total_cost",
"vat",
"contract_amount",
"grand_total",
}
def _run(waste: Decimal, separate: bool = False):
return calculate_cost(
CostInput(**BASE, waste_disposal_krw=waste, waste_separate_order=separate)
)
def test_기본은_경비로_들어_일반관리비_이윤_밑수가_는다() -> None:
before, after = _run(Decimal(0)), _run(WASTE)
assert after.line("waste_disposal").amount_krw == WASTE
assert after.amount("expense") - before.amount("expense") == WASTE
assert after.amount("net_construction_cost") - before.amount("net_construction_cost") == WASTE
over = (
after.line("general_overhead").base_amount_krw
- before.line("general_overhead").base_amount_krw
)
assert over == WASTE
overhead_gain = after.amount("general_overhead") - before.amount("general_overhead")
assert overhead_gain > 0
profit_base_gain = (
after.line("profit_before_adjustment").base_amount_krw
- before.line("profit_before_adjustment").base_amount_krw
)
assert profit_base_gain == WASTE + overhead_gain
assert "폐기물처리비" in after.line("expense").base_label
def test_법정경비_줄은_안_움직인다() -> None:
before, after = _run(Decimal(0)), _run(WASTE)
for line in before.lines:
if line.key not in MOVES:
assert after.amount(line.key) == line.amount_krw, line.key
def test_경비_줄보다_앞에_선다() -> None:
keys = [line.key for line in _run(WASTE).lines]
assert keys.index("waste_disposal") < keys.index("expense") < keys.index("general_overhead")
def test_분리발주면_총원가_밖_총공사비에만() -> None:
none, separate = _run(Decimal(0)), _run(WASTE, separate=True)
assert separate.amount("total_cost") == none.amount("total_cost")
assert separate.amount("grand_total") - none.amount("grand_total") == WASTE
keys = [line.key for line in separate.lines]
assert keys.index("waste_disposal") > keys.index("contract_amount")
assert separate.totals["waste_disposal"] == WASTE
def test_금액이_없으면_줄이_안_선다() -> None:
assert not _run(Decimal(0)).has("waste_disposal")
+21
View File
@@ -711,6 +711,27 @@ export const ui_locales_b2 = {
"기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)",
"Off by default — turn on per site. Volume must be entered for the row to carry a quantity",
],
B08_Quantity_Side_TreeWaste: ["임목폐기물", "Tree Waste"],
B08_Quantity_TreeWaste_Stems: ["표본지 1,000㎡당 본수", "Stems per 1,000 ㎡ (sample plot)"],
B08_Quantity_TreeWaste_Dbh: ["평균 흉고직경(㎝)", "Mean DBH (cm)"],
B08_Quantity_TreeWaste_Height: ["평균 수고(m)", "Mean tree height (m)"],
B08_Quantity_TreeWaste_UnitWeight: ["단위체적중량(㎏/㎥)", "Unit weight (kg/㎥)"],
B08_Quantity_Side_TreeWaste_Hint: [
"현장 조사값 넷을 넣으면 준비공 「임목폐기물 처리」가 톤으로 섭니다. 산식: 지상부 WA = 0.5 × π × (흉고직경/2)² × 수고 × (1+0.3) × 단위체적중량 × 본수 · 뿌리부 WR = WA × 15/85 · 본수 = 1,000㎡당 본수 × 벌목·지장목제거 면적 — 한국건설기술연구원(2012) 3.2. 단위체적중량 참고: 소나무·침엽수 1,210~1,250 · 단풍·산벚 1,250~1,300 · 느티 1,300~1,340 · 상수리·졸참 1,340 이상(조경설계요람). 기본값 없음 — 수종·임분마다 다름",
"Enter the four field-survey values to raise the tree-waste row in tonnes. WA = 0.5 × π × (DBH/2)² × height × 1.3 × unit weight × stems; roots WR = WA × 15/85 (KICT 2012). No defaults — values differ by species and stand",
],
B08_Quantity_TreeWaste_Price: ["처리단가(원/톤) — 수동", "Disposal price (KRW/t) — manual"],
B08_Quantity_TreeWaste_Price_Hint: [
"⚠ 공표 단가 없음(한국건설자원협회 중간처리단가 미확보) — 넣으면 금액이 서되 「미확정」으로 셉니다(빨간 테두리). 비목은 경비 — 원가계산서 폐기물처리비 줄로 가고 일반관리비·이윤 밑수에 듭니다(예정가격작성기준 제19조③18호)",
"No published price — a manual price raises the amount as unconfirmed (red outline). Cost category is expense, inside the overhead and profit bases",
],
B08_Quantity_TreeWaste_Separate: ["분리발주", "Separate disposal contract"],
B08_Quantity_TreeWaste_Separate_Off: ["분리발주 아님(기본)", "No (default)"],
B08_Quantity_TreeWaste_Separate_On: ["분리발주", "Yes"],
B08_Quantity_TreeWaste_Separate_Hint: [
"기본은 분리발주 아님 — 원가계산서 경비에 듭니다. 켜면 총원가 밖에 서고 총공사비에만 더합니다. 참고: 건설폐기물 위탁처리 100톤 이상은 분리발주 대상(건설폐기물 업무처리지침)이나, 같은 지침은 임목폐기물을 건설폐기물이 아닌 사업장 일반폐기물로 분류함 · 5톤 이상은 배출자 신고 대상(폐기물관리법 시행령 제2조8호)",
"Default: not separate — included in the expense category. When on, it sits outside total cost. Note: the 100 t separate-order rule is for construction waste; tree waste is classified as business general waste. 5 t or more requires a discharge report",
],
B08_Quantity_Side_SubgradeCompaction: ["노체다짐", "Subgrade Compaction"],
B08_Quantity_SubgradeCompaction_Label: ["노체다짐을 셀 것인가", "Count subgrade compaction"],
B08_Quantity_Side_SubgradeCompaction_Hint: [