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:
2026-09-09 20:30:10 +09:00
co-authored by Claude Opus 5
parent 404ec16e65
commit 83c842e37a
8 changed files with 445 additions and 5 deletions
+6 -2
View File
@@ -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]:
"""상세가 가리키는데 제목이 없는 코드 — **빈칸으로 두지 않고 목록으로 낸다**.
+49 -1
View File
@@ -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})
+12
View File
@@ -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))),
)
+89 -2
View File
@@ -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) # 「25%**까지**」 — 상한
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)
+161
View File
@@ -0,0 +1,161 @@
"""공구손료·잡재료 칸 — 「지금은 안 넣되 숫자 넣으면 되게」 (2026-09-09 확정 5차 작은 것 1).
근거는 산림품셈 1-2-6 명시되어 있지 않는 잡재료 소모재료 등을 계상하고자 때에는
**주재료비(재료비의 할증수량 제외) 25%까지** 별도 계상하되 산정 근거를 명시하여야 한다.
겨누는 다섯
**비면 줄이 아예 선다** 지금 금액이 원도 움직여야
넣으면 **재료비** 붙는다(경비 아님) 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:
"""④ 「25%까지」 — 넘는 값을 조용히 깎아 넣지 않고 거절함."""
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