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
+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})