feat(B09): 공구손료·잡재료 칸 — 기본은 빔, 넣으면 주재료비의 %로 붙음
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」. 근거는 산림품셈 1-2-6 — 주재료비(할증수량 제외)의 2~5%까지, 산정 근거 명시. - 기초자료 탭 「산출 조건」에 칸 하나 + [적용]. 비면 줄 자체가 안 섬(지금 상태 그대로). - 밑수는 **자재 줄만** — 노무·경비, 하위 일위대가 재료비는 안 듦(층마다 거듭 세지 않음). - 상한 5% 초과는 거절(400) — 조용히 깎아 넣지 않음. - ⚠ 지금은 일위대가에 주재료비가 선 공종이 0개라 붙을 밑수가 없음 — 그 사실을 칸 밑에 띄움. - 곁다리: 비율 줄(제잡비·공구손료)이 저장했다 읽으면 사라지던 것을 고침. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,8 +161,12 @@ def resource_summary(
|
||||
continue
|
||||
amount = Decimal(str(quantity))
|
||||
for detail in book.details.get(code, []):
|
||||
if detail.percent_of_labor is not None or detail.percent_of_parent is not None:
|
||||
continue # 비율 줄은 자원이 아니다 — 경비로만 붙는다
|
||||
if (
|
||||
detail.percent_of_labor is not None
|
||||
or detail.percent_of_parent is not None
|
||||
or detail.percent_of_material is not None
|
||||
):
|
||||
continue # 비율 줄은 자원이 아니다 — 목록표에 설 자재·노임이 없다
|
||||
ref = detail.ref_code
|
||||
if ref == code:
|
||||
continue
|
||||
|
||||
@@ -152,6 +152,12 @@ class PriceDetail:
|
||||
#: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고,
|
||||
#: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다.
|
||||
percent_of_labor: Decimal | None = None
|
||||
#: **주재료비**의 %로 붙는 재료비 줄 — 공구손료·잡재료(산림품셈 1-2-6).
|
||||
#: ⚠ 밑수는 **자재 카탈로그(M)에서 온 재료비만**이다. 하위 일위대가가 품고 온 재료비는
|
||||
#: 그 일위대가에서 이미 한 번 셌으므로 여기서 또 세지 않는다.
|
||||
#: ⚠ 원문이 「재료비의 **할증수량 제외**」라 밑수가 **할증 전** 값이어야 하는데, 일위대가
|
||||
#: 층의 재료비가 곧 할증 전 값이다(할증은 자재총괄에서 한 번만 — PLAN 8-7 ㉠).
|
||||
percent_of_material: Decimal | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -200,6 +206,8 @@ class PriceBook:
|
||||
# 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다.
|
||||
# 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋.
|
||||
direct_labor = Decimal(0)
|
||||
# 공구손료·잡재료 밑수로 쓸 **주재료비** — 자재 카탈로그에서 바로 온 재료비만 센다.
|
||||
direct_material = Decimal(0)
|
||||
for row in rows:
|
||||
# ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로
|
||||
# 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다).
|
||||
@@ -236,6 +244,20 @@ class PriceBook:
|
||||
total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100))
|
||||
continue
|
||||
|
||||
if row.percent_of_material is not None:
|
||||
# 공구손료·잡재료 — **주재료비**의 %가 **재료비**로 붙는다(산림품셈 1-2-6
|
||||
# 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 … 주재료비(재료비의
|
||||
# 할증수량 제외)의 2~5%까지 별도 계상하되 산정 근거를 명시하여야 한다」).
|
||||
# ⚠ **기본은 빈 칸이라 이 줄 자체가 안 선다**(사용자 확정 5차 작은 것 1
|
||||
# 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」). 몇 %인지는 사용자 몫이다.
|
||||
# ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는
|
||||
# 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼
|
||||
# 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.**
|
||||
total = total + Money3(
|
||||
material=direct_material * row.percent_of_material / Decimal(100)
|
||||
)
|
||||
continue
|
||||
|
||||
child = self.resolve(row.ref_code, (*_seen, code))
|
||||
if row.percent_of_parent is not None:
|
||||
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
|
||||
@@ -244,9 +266,29 @@ class PriceBook:
|
||||
scaled = child.scaled(row.quantity)
|
||||
if self.titles[row.ref_code].kind is PriceKind.LABOR:
|
||||
direct_labor = direct_labor + scaled.labor
|
||||
if self.titles[row.ref_code].kind is PriceKind.MATERIAL:
|
||||
direct_material = direct_material + scaled.material
|
||||
total = total + scaled
|
||||
return total
|
||||
|
||||
def material_base(self, code: str) -> Decimal:
|
||||
"""공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합.
|
||||
|
||||
`resolve` 안의 `direct_material` 과 같은 규칙이다. 화면이 「넣을 데가 있는가」를
|
||||
미리 물어볼 수 있게 따로 낸다 — 지금은 사급 단가가 미결이라 대부분 0 이고,
|
||||
그 사실을 숨기면 사용자가 값을 넣고도 왜 안 붙는지 모른다.
|
||||
"""
|
||||
base = _ZERO
|
||||
for row in self.details.get(code, []):
|
||||
if row.percent_of_material is not None or row.percent_of_labor is not None:
|
||||
continue
|
||||
if row.percent_of_parent is not None or row.ref_code == code:
|
||||
continue
|
||||
title = self.titles.get(row.ref_code)
|
||||
if title is not None and title.kind is PriceKind.MATERIAL:
|
||||
base = base + title.catalog_money().material * row.quantity
|
||||
return base
|
||||
|
||||
def unmatched_codes(self) -> list[str]:
|
||||
"""상세가 가리키는데 제목이 없는 코드 — **빈칸으로 두지 않고 목록으로 낸다**.
|
||||
|
||||
|
||||
@@ -247,7 +247,8 @@ async def _build_for(project_id: UUID):
|
||||
machines = tuple(
|
||||
sorted((str(k), str(v)) for k, v in (settings.get("machine_choices") or {}).items())
|
||||
)
|
||||
return cached_build(ranges, machines)
|
||||
# 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1).
|
||||
return cached_build(ranges, machines, str(settings.get("misc_material_percent") or ""))
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/base-data")
|
||||
@@ -370,11 +371,46 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
||||
}
|
||||
)
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||
MISC_MATERIAL_MAX_PERCENT,
|
||||
MISC_MATERIAL_MIN_PERCENT,
|
||||
)
|
||||
|
||||
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
|
||||
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
|
||||
book = (await _build_for(project_id)).book
|
||||
with_material = sum(
|
||||
1
|
||||
for unit_code, unit_title in book.titles.items()
|
||||
if unit_title.kind is PriceKind.UNIT_PRICE and book.material_base(unit_code) > 0
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"ranges": ranges,
|
||||
"machines": machines,
|
||||
"misc_material": {
|
||||
"percent": str(settings.get("misc_material_percent") or ""),
|
||||
"min": str(MISC_MATERIAL_MIN_PERCENT),
|
||||
"max": str(MISC_MATERIAL_MAX_PERCENT),
|
||||
"basis": [
|
||||
"산림품셈 1-2-6 — 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 등을"
|
||||
" 계상하고자 할 때에는 주재료비(재료비의 할증수량 제외)의 2~5%까지"
|
||||
" 별도 계상하되 산정 근거를 명시하여야 한다」",
|
||||
"⚠ 비워 두면 안 붙습니다 — 지금은 안 붙고 있는 상태입니다"
|
||||
" (사용자 확정 2026-09-09 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」).",
|
||||
],
|
||||
"base_items": with_material,
|
||||
"base_note": (
|
||||
""
|
||||
if with_material
|
||||
else "⚠ 지금은 일위대가에 주재료비가 선 공종이 하나도 없습니다"
|
||||
" — 자재는 자재대 표에서 따로 금액이 섭니다. 값을 넣어도 붙을 밑수가"
|
||||
" 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다."
|
||||
),
|
||||
},
|
||||
"notes": [
|
||||
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
|
||||
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
|
||||
@@ -394,6 +430,8 @@ class FactorChoiceBody(BaseModel):
|
||||
|
||||
range_factor_choices: dict[str, str] | None = None
|
||||
machine_choices: dict[str, str] | None = None
|
||||
#: 공구손료·잡재료 비율 — **빈 문자열이면 안 붙는다**(칸을 도로 비우는 길).
|
||||
misc_material_percent: str | None = None
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation/factors")
|
||||
@@ -422,6 +460,16 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
|
||||
for key, value in body.machine_choices.items()
|
||||
if str(value) in MACHINE_OPTION_CODES
|
||||
}
|
||||
if body.misc_material_percent is not None:
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import parse_misc_material_percent
|
||||
|
||||
try:
|
||||
percent = parse_misc_material_percent(body.misc_material_percent)
|
||||
except ValueError as exc:
|
||||
# ⚠ 조용히 깎아 넣지 않는다 — 범위 밖 값을 상한으로 접으면 사용자가 넣은 값과
|
||||
# 금액이 어긋난 채로 선다.
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
values["misc_material_percent"] = "" if percent is None else str(percent)
|
||||
try:
|
||||
save_section(root, "estimation", values, replace_keys=tuple(values))
|
||||
return JSONResponse(content={"status": "success", **values})
|
||||
|
||||
@@ -119,17 +119,29 @@ def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]:
|
||||
"percent_of_parent": (
|
||||
None if detail.percent_of_parent is None else str(detail.percent_of_parent)
|
||||
),
|
||||
# ⚠ 비율 줄 셋을 **다 적는다** — 하나라도 빠뜨리면 저장했다 다시 읽을 때 그 줄이
|
||||
# 조용히 사라져 단가가 낮아진다(제잡비가 그 자리였다).
|
||||
"percent_of_labor": (
|
||||
None if detail.percent_of_labor is None else str(detail.percent_of_labor)
|
||||
),
|
||||
"percent_of_material": (
|
||||
None if detail.percent_of_material is None else str(detail.percent_of_material)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail:
|
||||
percent = raw.get("percent_of_parent")
|
||||
labor_percent = raw.get("percent_of_labor")
|
||||
material_percent = raw.get("percent_of_material")
|
||||
return PriceDetail(
|
||||
parent_code=raw["parent_code"],
|
||||
ref_code=raw["ref_code"],
|
||||
quantity=Decimal(str(raw.get("quantity", "0"))),
|
||||
note=raw.get("note", ""),
|
||||
percent_of_parent=None if percent is None else Decimal(str(percent)),
|
||||
percent_of_labor=None if labor_percent is None else Decimal(str(labor_percent)),
|
||||
percent_of_material=(None if material_percent is None else Decimal(str(material_percent))),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -486,10 +486,22 @@ export interface MachineChoiceRow {
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙는다(산림품셈 1-2-6). */
|
||||
export interface MiscMaterialRow {
|
||||
percent: string;
|
||||
min: string;
|
||||
max: string;
|
||||
basis: string[];
|
||||
/** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */
|
||||
base_items: number;
|
||||
base_note: string;
|
||||
}
|
||||
|
||||
export interface FactorChoicesDto {
|
||||
status: string;
|
||||
ranges: RangeFactorRow[];
|
||||
machines: MachineChoiceRow[];
|
||||
misc_material?: MiscMaterialRow;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
@@ -504,7 +516,11 @@ export async function fetchFactorChoices(projectId: string): Promise<FactorChoic
|
||||
|
||||
export async function saveFactorChoices(
|
||||
projectId: string,
|
||||
body: { range_factor_choices?: Record<string, string>; machine_choices?: Record<string, string> },
|
||||
body: {
|
||||
range_factor_choices?: Record<string, string>;
|
||||
machine_choices?: Record<string, string>;
|
||||
misc_material_percent?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
|
||||
@@ -515,7 +531,56 @@ export async function saveFactorChoices(
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`factors save ${response.status}`);
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.json()
|
||||
.then((body: { message?: string }) => body.message ?? "")
|
||||
.catch(() => "");
|
||||
throw new Error(message || `factors save ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 칸 하나 — **빈 칸이 기본**이다. [적용]을 눌러야 저장된다.
|
||||
*
|
||||
* ⚠ 고르는 칸(`picker`)과 달리 여기는 **사용자가 값을 짓는 자리**라 누를 때만 보낸다 —
|
||||
* 타자 한 자마다 보내면 「2」를 치는 도중에 2% 로 저장돼 버린다.
|
||||
*/
|
||||
function percentBox(
|
||||
label: string,
|
||||
value: string,
|
||||
placeholder: string,
|
||||
onApply: (text: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b09-hint";
|
||||
wrap.style.display = "flex";
|
||||
wrap.style.alignItems = "center";
|
||||
wrap.style.gap = "8px";
|
||||
wrap.style.flexWrap = "wrap";
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.style.fontWeight = "600";
|
||||
name.textContent = label;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "0.1";
|
||||
input.min = "0";
|
||||
input.value = value;
|
||||
input.placeholder = placeholder;
|
||||
input.style.width = "72px";
|
||||
|
||||
const unit = document.createElement("span");
|
||||
unit.textContent = "%";
|
||||
|
||||
const apply = document.createElement("button");
|
||||
apply.type = "button";
|
||||
apply.textContent = "적용";
|
||||
apply.addEventListener("click", () => onApply(input.value.trim()));
|
||||
|
||||
wrap.append(name, input, unit, apply);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function picker(
|
||||
@@ -599,5 +664,27 @@ export function drawFactorChoices(
|
||||
for (const line of row.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
const misc = data.misc_material;
|
||||
if (misc) {
|
||||
body.append(
|
||||
percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) => {
|
||||
void saveFactorChoices(projectId, { misc_material_percent: text })
|
||||
.then(reload)
|
||||
.catch((error: Error) => {
|
||||
body.append(note(`⚠ ${error.message}`));
|
||||
});
|
||||
}),
|
||||
);
|
||||
body.append(
|
||||
note(
|
||||
misc.percent
|
||||
? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.`
|
||||
: `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`,
|
||||
),
|
||||
);
|
||||
if (misc.base_note) body.append(note(misc.base_note));
|
||||
for (const line of misc.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
for (const line of data.notes) body.append(note(line));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from dataclasses import dataclass, field
|
||||
from dataclasses import replace as dataclass_replace
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
|
||||
from B09_Estimation.B09_Estimation_MachineCost import (
|
||||
@@ -157,6 +158,40 @@ _ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게")
|
||||
#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원).
|
||||
COMBINED_MISC_PERCENT = Decimal(16)
|
||||
|
||||
#: ⭐ 사용자 확정 5차 작은 것 1 — **공구손료·잡재료 칸**.
|
||||
#: 「지금은 안 넣음. 다만 숫자를 넣으면 되게 열어 둘 것 — 칸을 만들되 기본은 빔, 비면 안 붙음」
|
||||
#: 근거는 산림품셈 1-2-6 — 「각 항목에 명시되어 있는 잡재료 및 소모재료에 대해서는 이를
|
||||
#: 계상하고, 명시되어 있지 않는 … 주재료비(재료비의 할증수량 제외)의 **2~5%까지** 별도
|
||||
#: 계상하되 **산정 근거를 명시**하여야 한다」.
|
||||
#: ⚠ **몇 %인지는 사용자 몫이다** — 기본값을 만들어 두지 않는다(범위값을 임의로 굳히면
|
||||
#: 금액이 조용히 그 값으로 선다). 비어 있으면 줄 자체가 안 서고 지금 상태 그대로다.
|
||||
MISC_MATERIAL_MAX_PERCENT = Decimal(5) # 「2~5%**까지**」 — 상한
|
||||
MISC_MATERIAL_MIN_PERCENT = Decimal(2) # 원문이 적은 아랫값(아래로 내려가면 사유로 알린다)
|
||||
MISC_MATERIAL_BASIS = "공구손료·잡재료 — 주재료비의 {percent}% (산림품셈 1-2-6, 산정 근거 명시)"
|
||||
|
||||
|
||||
def parse_misc_material_percent(raw: Any) -> Decimal | None:
|
||||
"""설정 칸의 값을 비율로 읽는다. **비면 `None`**(= 안 붙음).
|
||||
|
||||
⚠ 상한(5%)을 넘는 값은 **받지 않는다** — 품셈이 「2~5%까지」로 못 박은 자리다.
|
||||
"""
|
||||
text = str(raw or "").strip().rstrip("%").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
percent = Decimal(text)
|
||||
except (ArithmeticError, ValueError):
|
||||
raise ValueError(f"공구손료·잡재료 비율을 숫자로 못 읽었습니다: {raw!r}") from None
|
||||
if percent <= 0:
|
||||
return None
|
||||
if percent > MISC_MATERIAL_MAX_PERCENT:
|
||||
raise ValueError(
|
||||
f"공구손료·잡재료는 주재료비의 {MISC_MATERIAL_MAX_PERCENT}% 까지입니다"
|
||||
f" (산림품셈 1-2-6) — 받은 값 {percent}%"
|
||||
)
|
||||
return percent
|
||||
|
||||
|
||||
#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다.
|
||||
#: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 ·
|
||||
#: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게.
|
||||
@@ -487,6 +522,7 @@ def build_unit_prices(
|
||||
axis: AxisResult | None = None,
|
||||
factor_choices: dict[tuple[str, str], Decimal] | None = None,
|
||||
machine_picks: dict[str, str] | None = None,
|
||||
misc_material_percent: Decimal | None = None,
|
||||
) -> UnitPriceBuild:
|
||||
"""자원 축을 일위대가(`B`)로 조립한다.
|
||||
|
||||
@@ -495,6 +531,9 @@ def build_unit_prices(
|
||||
|
||||
⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면
|
||||
**평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다.
|
||||
|
||||
⚠ `misc_material_percent` — 공구손료·잡재료(산림품셈 1-2-6). **안 주면 줄이 안 선다** —
|
||||
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||
chosen_values,
|
||||
@@ -698,6 +737,20 @@ def build_unit_prices(
|
||||
)
|
||||
)
|
||||
|
||||
# 공구손료·잡재료 — **주재료비의 %가 재료비로** 붙는다(산림품셈 1-2-6).
|
||||
# ⚠ **비어 있으면 이 줄이 아예 안 선다** = 지금까지와 같은 금액이다(확정 5차 작은 것 1).
|
||||
# ⚠ 갈래 제목(`__` 로 시작하는 내부 갈래)에는 안 붙인다 — 제잡비와 같은 자리를 쓴다.
|
||||
if misc_material_percent is not None and not variant_key.startswith("__"):
|
||||
build.book.add_detail(
|
||||
PriceDetail(
|
||||
title_code,
|
||||
title_code,
|
||||
_ZERO,
|
||||
note=MISC_MATERIAL_BASIS.format(percent=misc_material_percent),
|
||||
percent_of_material=misc_material_percent,
|
||||
)
|
||||
)
|
||||
|
||||
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
|
||||
# ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번
|
||||
# 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다.
|
||||
@@ -892,6 +945,7 @@ DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, Pri
|
||||
def cached_build(
|
||||
range_choices: tuple[tuple[str, str], ...] = (),
|
||||
machine_picks: tuple[tuple[str, str], ...] = (),
|
||||
misc_material_percent: str = "",
|
||||
) -> UnitPriceBuild:
|
||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
||||
|
||||
@@ -913,6 +967,7 @@ def cached_build(
|
||||
return build_unit_prices(
|
||||
factor_choices=chosen_values(scan_range_factors(master), settings),
|
||||
machine_picks=machine_choices(settings),
|
||||
misc_material_percent=parse_misc_material_percent(misc_material_percent),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -211,6 +211,37 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
||||
)
|
||||
continue
|
||||
|
||||
if detail.percent_of_material is not None:
|
||||
# 공구손료·잡재료 — 지금까지 쌓인 **주재료비**의 %가 재료비로 붙는다
|
||||
# (산림품셈 1-2-6). 밑수는 **자재 줄만** — 하위 일위대가가 품고 온 재료비는
|
||||
# 그쪽에서 이미 셌다(`PriceBook.resolve` 의 같은 자리와 한 규칙).
|
||||
material_so_far = sum(
|
||||
(
|
||||
Decimal(str(row_item["material"]))
|
||||
for row_item in rows
|
||||
if row_item.get("kind") == PriceKind.MATERIAL.value
|
||||
),
|
||||
_ZERO,
|
||||
)
|
||||
amount = material_so_far * detail.percent_of_material / Decimal(100)
|
||||
rows.append(
|
||||
{
|
||||
"code": detail.ref_code,
|
||||
"name": "공구손료·잡재료",
|
||||
"spec": f"주재료비의 {detail.percent_of_material}%",
|
||||
"unit": "%",
|
||||
"quantity": str(detail.percent_of_material),
|
||||
"material": str(amount),
|
||||
"labor": "0",
|
||||
"expense": "0",
|
||||
"total": _money_text(amount),
|
||||
"source": "품셈 1-2-6",
|
||||
"drillable": False,
|
||||
"note": detail.note,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
child = build.book.title(detail.ref_code)
|
||||
unit_money = build.book.resolve(detail.ref_code)
|
||||
line = unit_money.scaled(detail.quantity)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""공구손료·잡재료 칸 — 「지금은 안 넣되 숫자 넣으면 되게」 (2026-09-09 확정 5차 작은 것 1).
|
||||
|
||||
근거는 산림품셈 1-2-6 — 「명시되어 있지 않는 잡재료 및 소모재료 등을 계상하고자 할 때에는
|
||||
**주재료비(재료비의 할증수량 제외)의 2~5%까지** 별도 계상하되 산정 근거를 명시하여야 한다」.
|
||||
|
||||
⚠ 겨누는 것 다섯
|
||||
① **비면 줄이 아예 안 선다** — 지금 금액이 한 원도 안 움직여야 함
|
||||
② 넣으면 **재료비**로 붙는다(경비 아님) — 1-2-6 은 잡재료·소모재료 자리임
|
||||
③ 밑수는 **주재료비만** — 노무·경비는 안 들고, 하위 일위대가 재료비도 안 듦
|
||||
(그쪽에서 이미 한 번 셌음 — 두 번 세면 층이 깊을수록 부풀어 오름)
|
||||
④ **상한 5% 를 넘는 값은 안 받는다** — 조용히 깎아 넣지도 않음
|
||||
⑤ 저장했다 다시 읽어도 그 줄이 살아 있다 (비율 줄이 직렬화에서 빠지면 조용히 싸짐)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
|
||||
PriceBook,
|
||||
PriceDetail,
|
||||
PriceKind,
|
||||
PriceTitle,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Storage import ( # noqa: E402
|
||||
_detail_from_dict,
|
||||
_detail_to_dict,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402
|
||||
MISC_MATERIAL_MAX_PERCENT,
|
||||
parse_misc_material_percent,
|
||||
)
|
||||
|
||||
|
||||
def _book(percent: Decimal | None) -> PriceBook:
|
||||
"""자재 100,000 + 노임 50,000 짜리 일위대가 한 줄."""
|
||||
book = PriceBook()
|
||||
book.add_title(
|
||||
PriceTitle(code="M-1", kind=PriceKind.MATERIAL, name="자재", slots=[Decimal(100_000)] * 6)
|
||||
)
|
||||
book.add_title(
|
||||
PriceTitle(code="L-1", kind=PriceKind.LABOR, name="보통인부", slots=[Decimal(50_000)] * 6)
|
||||
)
|
||||
book.add_title(PriceTitle(code="B-1", kind=PriceKind.UNIT_PRICE, name="시험공종"))
|
||||
book.add_detail(PriceDetail("B-1", "M-1", Decimal(1)))
|
||||
book.add_detail(PriceDetail("B-1", "L-1", Decimal(1)))
|
||||
if percent is not None:
|
||||
book.add_detail(PriceDetail("B-1", "B-1", Decimal(0), percent_of_material=percent))
|
||||
return book
|
||||
|
||||
|
||||
def test_비면_한_원도_안_움직인다() -> None:
|
||||
"""① 「지금은 안 넣음」 — 칸이 비면 줄 자체가 안 서야 함."""
|
||||
money = _book(None).resolve("B-1")
|
||||
assert money.material == Decimal(100_000)
|
||||
assert money.total == Decimal(150_000)
|
||||
|
||||
|
||||
def test_넣으면_재료비로_붙는다() -> None:
|
||||
"""② 경비가 아니라 재료비 — 1-2-6 은 잡재료·소모재료 자리."""
|
||||
money = _book(Decimal(3)).resolve("B-1")
|
||||
assert money.material == Decimal(103_000)
|
||||
assert money.labor == Decimal(50_000)
|
||||
assert money.expense == Decimal(0)
|
||||
|
||||
|
||||
def test_밑수는_주재료비만이다() -> None:
|
||||
"""③ 노무비가 밑수에 들면 3% 가 4.5% 처럼 서게 됨."""
|
||||
money = _book(Decimal(3)).resolve("B-1")
|
||||
붙은값 = money.material - Decimal(100_000)
|
||||
assert 붙은값 == Decimal(100_000) * Decimal(3) / Decimal(100)
|
||||
|
||||
|
||||
def test_하위_일위대가_재료비는_밑수에_안_든다() -> None:
|
||||
"""③ 층이 깊어질수록 같은 재료비를 거듭 세면 안 됨."""
|
||||
book = _book(Decimal(3))
|
||||
book.add_title(PriceTitle(code="B-2", kind=PriceKind.UNIT_PRICE, name="윗공종"))
|
||||
book.add_detail(PriceDetail("B-2", "B-1", Decimal(1)))
|
||||
book.add_detail(PriceDetail("B-2", "B-2", Decimal(0), percent_of_material=Decimal(3)))
|
||||
# B-1 이 품고 온 재료비(103,000)는 B-2 의 밑수가 아니다 — B-2 엔 제 자재 줄이 없다.
|
||||
assert book.resolve("B-2").material == book.resolve("B-1").material
|
||||
|
||||
|
||||
def test_상한을_넘으면_안_받는다() -> None:
|
||||
"""④ 「2~5%까지」 — 넘는 값을 조용히 깎아 넣지 않고 거절함."""
|
||||
with pytest.raises(ValueError) as caught:
|
||||
parse_misc_material_percent("6")
|
||||
assert str(MISC_MATERIAL_MAX_PERCENT) in str(caught.value)
|
||||
assert parse_misc_material_percent("") is None
|
||||
assert parse_misc_material_percent(" ") is None
|
||||
assert parse_misc_material_percent("3.5%") == Decimal("3.5")
|
||||
|
||||
|
||||
def test_밑수가_있는지_미리_물어볼_수_있다() -> None:
|
||||
"""화면이 「넣을 데가 있는가」를 물어보는 자리 — 없으면 0 이라고 말해야 함."""
|
||||
book = _book(None)
|
||||
assert book.material_base("B-1") == Decimal(100_000)
|
||||
book.add_title(PriceTitle(code="B-9", kind=PriceKind.UNIT_PRICE, name="자재 없는 공종"))
|
||||
book.add_detail(PriceDetail("B-9", "L-1", Decimal(1)))
|
||||
assert book.material_base("B-9") == Decimal(0)
|
||||
|
||||
|
||||
def test_저장했다_읽어도_비율_줄이_산다() -> None:
|
||||
"""⑤ 직렬화에서 빠지면 다시 읽은 단가가 조용히 싸짐."""
|
||||
original = PriceDetail(
|
||||
"B-1", "B-1", Decimal(0), note="공구손료", percent_of_material=Decimal(3)
|
||||
)
|
||||
again = _detail_from_dict(_detail_to_dict(original))
|
||||
assert again.percent_of_material == Decimal(3)
|
||||
# 제잡비(노무비의 %)도 같은 자리에서 빠져 있었다 — 함께 살린다.
|
||||
labor_row = PriceDetail("B-1", "B-1", Decimal(0), percent_of_labor=Decimal(5))
|
||||
assert _detail_from_dict(_detail_to_dict(labor_row)).percent_of_labor == Decimal(5)
|
||||
|
||||
|
||||
def test_칸을_저장했다_지웠다_할_수_있다(tmp_path, monkeypatch) -> None:
|
||||
"""화면 [적용] 이 지나는 길 — 넣기·되비우기·상한 거절 셋을 한자리에서 본다.
|
||||
|
||||
⚠ **되비우는 길이 있어야 한다** — 한번 넣으면 못 지우는 칸이면 「지금은 안 넣음」으로
|
||||
돌아갈 수 없다.
|
||||
"""
|
||||
from B09_Estimation import B09_Estimation_Router as router
|
||||
|
||||
project_id = uuid4()
|
||||
monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path)))
|
||||
|
||||
def _put(value: str):
|
||||
body = router.FactorChoiceBody(misc_material_percent=value)
|
||||
got = asyncio.run(router.put_factor_choices(project_id, body))
|
||||
return got.status_code, json.loads(got.body.decode())
|
||||
|
||||
from common_util.common_util_project_settings import estimation_settings
|
||||
|
||||
status, _ = _put("3")
|
||||
assert status == 200
|
||||
assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3"
|
||||
|
||||
status, payload = _put("6")
|
||||
assert status == 400 and "1-2-6" in payload["message"]
|
||||
# ⚠ 거절된 값이 저장을 건드리면 안 된다 — 3% 가 그대로 살아 있어야 한다.
|
||||
assert estimation_settings(str(tmp_path)).get("misc_material_percent") == "3"
|
||||
|
||||
status, _ = _put("")
|
||||
assert status == 200
|
||||
assert estimation_settings(str(tmp_path)).get("misc_material_percent") == ""
|
||||
|
||||
|
||||
def _fake_root(path: str):
|
||||
async def _root(_project_id):
|
||||
return path
|
||||
|
||||
return _root
|
||||
Reference in New Issue
Block a user