Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
"""B09 원가계산 — **품의 할인·할증 26계열** (산림품셈 1-4).
|
||||||
|
|
||||||
|
**무엇이 빠져 있었나** — 품셈 1-4 가 작업시기·경사도·이동거리 등 **26개 표**로 품을 할인·할증
|
||||||
|
하는데 한 계열도 안 붙고 있었다(자재 할증만 있었다).
|
||||||
|
|
||||||
|
**어디에 붙나 — 품(인력) 줄이다.**
|
||||||
|
원문 제목이 「1-4. **품**의 할인·할증」이고, 1-6 라 표가 직접노무비를 산림품셈으로 낸다.
|
||||||
|
⚠ **B08 물량에 곱하면 안 된다** — 자재·기계까지 함께 부풀어 그것이 곧 이중계상이다.
|
||||||
|
|
||||||
|
**⚠ 우리가 안 정하는 것 둘 — 원문이 안 정해 사용자에게 남긴다**
|
||||||
|
㉠ **어느 공종에 붙나** — 26계열의 [주]가 거의 다 조림·숲가꾸기·방제 전용을 지목한다
|
||||||
|
(1-4-1 「어린나무가꾸기에 한하여」 · 1-4-2 「줄베기」 · 1-4-9 「숲가꾸기 및 병해충방제
|
||||||
|
작업로」…). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
|
||||||
|
각 계열의 **[주] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
|
||||||
|
㉡ **여럿을 고를 때 합산인가 곱인가** — 원문에 없다. 지금은 **합산**으로 두고 그 사실을
|
||||||
|
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
|
||||||
|
|
||||||
|
**기본은 「안 고름」** — 한 계열도 안 고르면 금액이 한 원도 안 움직인다.
|
||||||
|
|
||||||
|
⚠ **26표 가운데 율 표는 24개**다. 둘은 성격이 달라 여기 안 담는다(버린 것이 아니라 다른 것).
|
||||||
|
1-4-24 방제장비 규격 — 「**장비품의** -20%」라 인력 품이 아니고 표기도 율이 아니다.
|
||||||
|
1-4-26 소규모 작업물량 제한 — 율이 아니라 **적용시공량 규칙**(`Q = B/2`)이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_DATASET = ("resources", "data_cost_input_value", "coef_2026.json")
|
||||||
|
_FOREST_SPEC = (
|
||||||
|
"resources",
|
||||||
|
"knowledge",
|
||||||
|
"original",
|
||||||
|
"행정규칙",
|
||||||
|
"임도 품셈 적용기준 (현 산림사업 표준품셈)",
|
||||||
|
"첨부",
|
||||||
|
"(산림청고시 제2025-82호) 산림사업 표준품셈.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 표 아래 [주] 를 몇 줄까지 읽나. [주] 는 표 바로 밑에 붙는다.
|
||||||
|
_NOTE_LOOKAHEAD = 12
|
||||||
|
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
|
||||||
|
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
|
||||||
|
|
||||||
|
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
|
||||||
|
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
|
||||||
|
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 − 1 = 15.5%
|
||||||
|
COMBINE_RULE = "sum"
|
||||||
|
COMBINE_NOTE = (
|
||||||
|
"⚠ 여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
|
||||||
|
" (사용자 확정 대기)."
|
||||||
|
)
|
||||||
|
SEAT_NOTE = (
|
||||||
|
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
|
||||||
|
" 이중계상이 됩니다(산림품셈 1-4 「품의 할인·할증」·1-6 라)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root() -> str:
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(*parts: str) -> Any:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _source_lines() -> tuple[str, ...]:
|
||||||
|
path = os.path.join(_project_root(), *_FOREST_SPEC)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return ()
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
return tuple(handle.read().splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def _note_of(line_no: int) -> str:
|
||||||
|
"""표 바로 아래 [주] 원문. 없으면 빈 문자열 — **지어내지 않는다.**"""
|
||||||
|
lines = _source_lines()
|
||||||
|
if not lines or line_no <= 0:
|
||||||
|
return ""
|
||||||
|
picked: list[str] = []
|
||||||
|
for index in range(line_no, min(len(lines), line_no + _NOTE_LOOKAHEAD)):
|
||||||
|
text = lines[index].strip()
|
||||||
|
if text.startswith("###"):
|
||||||
|
break
|
||||||
|
if text.startswith("[주]") or (picked and text.startswith("-")):
|
||||||
|
picked.append(text)
|
||||||
|
return " ".join(picked)
|
||||||
|
|
||||||
|
|
||||||
|
def _percent_of(cells: list[str]) -> Decimal | None:
|
||||||
|
"""행에서 율을 읽는다. **`%` 가 붙은 칸만** 율로 본다(경계값·면적과 헷갈리지 않게)."""
|
||||||
|
for cell in reversed(cells):
|
||||||
|
text = str(cell or "").strip()
|
||||||
|
if _RE_PERCENT.match(text):
|
||||||
|
return Decimal(text.rstrip("%"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_series() -> tuple[dict[str, Any], ...]:
|
||||||
|
"""26계열 — 원문 행을 **그대로** 선택지로 낸다. 값을 만들지 않는다."""
|
||||||
|
tables = _read_json(*_DATASET)["variables"]["surcharge_labor"]["tables"]
|
||||||
|
series: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for table in tables:
|
||||||
|
section = str(table.get("section") or "").strip()
|
||||||
|
found = _RE_SECTION.match(section)
|
||||||
|
if not found:
|
||||||
|
continue
|
||||||
|
key, title = found.group(1), found.group(2).strip()
|
||||||
|
# ⚠ 원문에 **같은 번호가 두 번** 나오는 자리가 있다(1-4-18). 뒤엣것을 버리지 않고
|
||||||
|
# 번호에 꼬리를 달아 둘 다 보인다 — 원문에 있는 표를 우리가 지우지 않는다.
|
||||||
|
if key in seen:
|
||||||
|
key = f"{key}b"
|
||||||
|
seen.add(key)
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
for row in table.get("rows") or []:
|
||||||
|
cells = [str(c or "").strip() for c in row]
|
||||||
|
percent = _percent_of(cells)
|
||||||
|
if percent is None:
|
||||||
|
continue
|
||||||
|
label = " · ".join(c for c in cells if c and not _RE_PERCENT.match(c))
|
||||||
|
options.append(
|
||||||
|
{"key": f"{key}:{len(options)}", "label": label or "(원문 행)", "percent": percent}
|
||||||
|
)
|
||||||
|
if not options:
|
||||||
|
continue
|
||||||
|
series.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"title": title,
|
||||||
|
"section": section,
|
||||||
|
"headers": list(table.get("headers") or []),
|
||||||
|
"options": options,
|
||||||
|
"source_note": _note_of(int(table.get("line") or 0)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(series)
|
||||||
|
|
||||||
|
|
||||||
|
def _option_map() -> dict[str, tuple[dict[str, Any], dict[str, Any]]]:
|
||||||
|
return {option["key"]: (item, option) for item in load_series() for option in item["options"]}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_choices(raw: Any) -> dict[str, str]:
|
||||||
|
"""설정에 저장된 선택 — **원문에 있는 선택지만** 받는다."""
|
||||||
|
known = _option_map()
|
||||||
|
picked: dict[str, str] = {}
|
||||||
|
for series_key, option_key in dict(raw or {}).items():
|
||||||
|
option = known.get(str(option_key))
|
||||||
|
if option is not None and option[0]["key"] == str(series_key):
|
||||||
|
picked[str(series_key)] = str(option_key)
|
||||||
|
return picked
|
||||||
|
|
||||||
|
|
||||||
|
def total_percent(choices: dict[str, str]) -> tuple[Decimal, list[str]]:
|
||||||
|
"""(합계 %, 줄에 남길 근거들). 아무것도 안 고르면 `(0, [])` — 줄이 안 선다.
|
||||||
|
|
||||||
|
⚠ 셈하는 법은 `COMBINE_RULE` **한 곳**이 정한다 — 원문이 안 정한 자리라 갈아 끼울 수
|
||||||
|
있어야 한다.
|
||||||
|
"""
|
||||||
|
known = _option_map()
|
||||||
|
picked: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
||||||
|
for option_key in choices.values():
|
||||||
|
found = known.get(str(option_key))
|
||||||
|
if found is not None:
|
||||||
|
picked.append(found)
|
||||||
|
|
||||||
|
reasons = [
|
||||||
|
f"{item['section']} {option['label']} {option['percent']:g}%" for item, option in picked
|
||||||
|
]
|
||||||
|
if COMBINE_RULE == "product":
|
||||||
|
factor = Decimal(1)
|
||||||
|
for _item, option in picked:
|
||||||
|
factor *= Decimal(1) + option["percent"] / Decimal(100)
|
||||||
|
return (factor - Decimal(1)) * Decimal(100), reasons
|
||||||
|
return sum((option["percent"] for _item, option in picked), Decimal(0)), reasons
|
||||||
@@ -152,6 +152,10 @@ class PriceDetail:
|
|||||||
#: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고,
|
#: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고,
|
||||||
#: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다.
|
#: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다.
|
||||||
percent_of_labor: Decimal | None = None
|
percent_of_labor: Decimal | None = None
|
||||||
|
#: 그 %가 **어느 성분으로** 들어가나 — 제잡비는 경비(기본), 품 할증은 **노무비**다.
|
||||||
|
#: ⚠ 품 할증(산림품셈 1-4)은 품을 늘리는 것이라 노무비로 들어가고, 그래서 **제잡비 밑수도
|
||||||
|
#: 함께 커진다** — 그 순서를 지키려고 조립하는 쪽이 할증 줄을 제잡비보다 먼저 넣는다.
|
||||||
|
percent_of_labor_target: str = "expense"
|
||||||
#: **주재료비**의 %로 붙는 재료비 줄 — 공구손료·잡재료(산림품셈 1-2-6).
|
#: **주재료비**의 %로 붙는 재료비 줄 — 공구손료·잡재료(산림품셈 1-2-6).
|
||||||
#: ⚠ 밑수는 **자재 카탈로그(M)에서 온 재료비만**이다. 하위 일위대가가 품고 온 재료비는
|
#: ⚠ 밑수는 **자재 카탈로그(M)에서 온 재료비만**이다. 하위 일위대가가 품고 온 재료비는
|
||||||
#: 그 일위대가에서 이미 한 번 셌으므로 여기서 또 세지 않는다.
|
#: 그 일위대가에서 이미 한 번 셌으므로 여기서 또 세지 않는다.
|
||||||
@@ -241,7 +245,14 @@ class PriceBook:
|
|||||||
# ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로
|
# ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로
|
||||||
# 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다.
|
# 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다.
|
||||||
# ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다.
|
# ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다.
|
||||||
total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100))
|
share = direct_labor * row.percent_of_labor / Decimal(100)
|
||||||
|
if row.percent_of_labor_target == "labor":
|
||||||
|
# 품 할인·할증(1-4) — **품이 늘어난 것**이라 노무비로 들어가고, 뒤에 오는
|
||||||
|
# 제잡비의 밑수에도 든다.
|
||||||
|
direct_labor = direct_labor + share
|
||||||
|
total = total + Money3(labor=share)
|
||||||
|
else:
|
||||||
|
total = total + Money3(expense=share)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if row.percent_of_material is not None:
|
if row.percent_of_material is not None:
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ async def _build_for(project_id: UUID):
|
|||||||
⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로
|
⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로
|
||||||
돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
|
돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
|
||||||
"""
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices as parse_labor_surcharge
|
||||||
from common_util.common_util_project_settings import estimation_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|
||||||
root = await _project_root_of(project_id)
|
root = await _project_root_of(project_id)
|
||||||
@@ -257,6 +258,8 @@ async def _build_for(project_id: UUID):
|
|||||||
str(settings.get("fuel_region") or ""),
|
str(settings.get("fuel_region") or ""),
|
||||||
str(settings.get("transport_distance_km") or ""),
|
str(settings.get("transport_distance_km") or ""),
|
||||||
str(settings.get("transport_road") or ""),
|
str(settings.get("transport_road") or ""),
|
||||||
|
# 품 할인·할증(1-4) — **안 고르면 안 붙는다.**
|
||||||
|
tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -400,6 +403,21 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
|||||||
|
|
||||||
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
|
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
|
||||||
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
|
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import (
|
||||||
|
COMBINE_NOTE as LABOR_SURCHARGE_COMBINE,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import SEAT_NOTE as LABOR_SURCHARGE_SEAT
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import load_series, parse_choices
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import total_percent
|
||||||
|
|
||||||
|
LABOR_SURCHARGE_SCOPE = (
|
||||||
|
"⚠ 26계열의 [주] 는 대개 조림·숲가꾸기·방제 작업을 지목합니다 — 임도 토공에 붙이라는"
|
||||||
|
" 지시가 원문에 없으므로, 각 계열의 [주] 를 보고 그 작업일 때만 고르십시오."
|
||||||
|
)
|
||||||
|
labor_surcharge_series = load_series()
|
||||||
|
labor_surcharge_chosen = parse_choices(settings.get("labor_surcharge"))
|
||||||
|
labor_surcharge_total, labor_surcharge_reasons = total_percent(labor_surcharge_chosen)
|
||||||
|
|
||||||
prices = await _build_for(project_id)
|
prices = await _build_for(project_id)
|
||||||
book = prices.book
|
book = prices.book
|
||||||
transport_notes = list(prices.transport_notes)
|
transport_notes = list(prices.transport_notes)
|
||||||
@@ -456,6 +474,28 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
|||||||
"basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION],
|
"basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION],
|
||||||
"notes": transport_notes,
|
"notes": transport_notes,
|
||||||
},
|
},
|
||||||
|
"labor_surcharge": {
|
||||||
|
"chosen": labor_surcharge_chosen,
|
||||||
|
"total_percent": f"{labor_surcharge_total:g}",
|
||||||
|
"reasons": labor_surcharge_reasons,
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"key": item["key"],
|
||||||
|
"title": item["title"],
|
||||||
|
"section": item["section"],
|
||||||
|
"source_note": item["source_note"],
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"key": option["key"],
|
||||||
|
"label": f"{option['label']} · {option['percent']:g}%",
|
||||||
|
}
|
||||||
|
for option in item["options"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for item in labor_surcharge_series
|
||||||
|
],
|
||||||
|
"basis": [LABOR_SURCHARGE_SEAT, LABOR_SURCHARGE_COMBINE, LABOR_SURCHARGE_SCOPE],
|
||||||
|
},
|
||||||
"notes": [
|
"notes": [
|
||||||
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
|
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
|
||||||
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
|
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
|
||||||
@@ -482,6 +522,8 @@ class FactorChoiceBody(BaseModel):
|
|||||||
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
|
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
|
||||||
transport_distance_km: str | None = None
|
transport_distance_km: str | None = None
|
||||||
transport_road: str | None = None
|
transport_road: str | None = None
|
||||||
|
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
|
||||||
|
labor_surcharge: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}/estimation/factors")
|
@router.put("/{project_id}/estimation/factors")
|
||||||
@@ -490,6 +532,8 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
|
|||||||
from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES
|
from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES
|
||||||
from common_util.common_util_project_settings import save_section
|
from common_util.common_util_project_settings import save_section
|
||||||
|
|
||||||
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|
||||||
root = await _project_root_of(project_id)
|
root = await _project_root_of(project_id)
|
||||||
if root is None:
|
if root is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -554,6 +598,17 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
|
|||||||
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
|
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
|
||||||
)
|
)
|
||||||
values["transport_road"] = road
|
values["transport_road"] = road
|
||||||
|
if body.labor_surcharge is not None:
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
|
||||||
|
|
||||||
|
# ⚠ **원문에 있는 선택지만** 받는다 — 없는 율이 설정으로 들어오면 그것이 임의 수치다.
|
||||||
|
stored = dict(estimation_settings(root).get("labor_surcharge") or {})
|
||||||
|
for series_key, option_key in body.labor_surcharge.items():
|
||||||
|
if str(option_key).strip():
|
||||||
|
stored[str(series_key)] = str(option_key)
|
||||||
|
else:
|
||||||
|
stored.pop(str(series_key), None) # 빈 값 = 그 계열 끄기
|
||||||
|
values["labor_surcharge"] = parse_choices(stored)
|
||||||
try:
|
try:
|
||||||
save_section(root, "estimation", values, replace_keys=tuple(values))
|
save_section(root, "estimation", values, replace_keys=tuple(values))
|
||||||
return JSONResponse(content={"status": "success", **values})
|
return JSONResponse(content={"status": "success", **values})
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]:
|
|||||||
"percent_of_labor": (
|
"percent_of_labor": (
|
||||||
None if detail.percent_of_labor is None else str(detail.percent_of_labor)
|
None if detail.percent_of_labor is None else str(detail.percent_of_labor)
|
||||||
),
|
),
|
||||||
|
"percent_of_labor_target": detail.percent_of_labor_target,
|
||||||
"percent_of_material": (
|
"percent_of_material": (
|
||||||
None if detail.percent_of_material is None else str(detail.percent_of_material)
|
None if detail.percent_of_material is None else str(detail.percent_of_material)
|
||||||
),
|
),
|
||||||
@@ -141,6 +142,7 @@ def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail:
|
|||||||
note=raw.get("note", ""),
|
note=raw.get("note", ""),
|
||||||
percent_of_parent=None if percent is None else Decimal(str(percent)),
|
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_labor=None if labor_percent is None else Decimal(str(labor_percent)),
|
||||||
|
percent_of_labor_target=str(raw.get("percent_of_labor_target") or "expense"),
|
||||||
percent_of_material=(None if material_percent is None else Decimal(str(material_percent))),
|
percent_of_material=(None if material_percent is None else Decimal(str(material_percent))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -546,12 +546,28 @@ export interface TransportRow {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 품의 할인·할증 26계열 — 안 고르면 안 붙는다(산림품셈 1-4). */
|
||||||
|
export interface LaborSurchargeRow {
|
||||||
|
chosen: Record<string, string>;
|
||||||
|
total_percent: string;
|
||||||
|
reasons: string[];
|
||||||
|
series: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
section: string;
|
||||||
|
source_note: string;
|
||||||
|
options: FactorOption[];
|
||||||
|
}>;
|
||||||
|
basis: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface FactorChoicesDto {
|
export interface FactorChoicesDto {
|
||||||
status: string;
|
status: string;
|
||||||
ranges: RangeFactorRow[];
|
ranges: RangeFactorRow[];
|
||||||
machines: MachineChoiceRow[];
|
machines: MachineChoiceRow[];
|
||||||
misc_material?: MiscMaterialRow;
|
misc_material?: MiscMaterialRow;
|
||||||
transport?: TransportRow;
|
transport?: TransportRow;
|
||||||
|
labor_surcharge?: LaborSurchargeRow;
|
||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +589,7 @@ export async function saveFactorChoices(
|
|||||||
fuel_region?: string;
|
fuel_region?: string;
|
||||||
transport_distance_km?: string;
|
transport_distance_km?: string;
|
||||||
transport_road?: string;
|
transport_road?: string;
|
||||||
|
labor_surcharge?: Record<string, string>;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -782,5 +799,29 @@ export function drawFactorChoices(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const surcharge = data.labor_surcharge;
|
||||||
|
if (surcharge) {
|
||||||
|
body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다"));
|
||||||
|
body.append(
|
||||||
|
note(
|
||||||
|
Number(surcharge.total_percent) === 0
|
||||||
|
? "지금 한 계열도 안 골라 한 원도 안 움직이고 있습니다."
|
||||||
|
: `지금 ${surcharge.total_percent}% 가 품에 붙고 있습니다 — ${surcharge.reasons.join(" · ")}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const line of surcharge.basis) body.append(note(line));
|
||||||
|
for (const item of surcharge.series) {
|
||||||
|
const options: FactorOption[] = [{ key: "", label: "안 고름" }, ...item.options];
|
||||||
|
body.append(
|
||||||
|
picker(`${item.section}`, options, surcharge.chosen[item.key] ?? "", (key) => {
|
||||||
|
void saveFactorChoices(projectId, { labor_surcharge: { [item.key]: key } })
|
||||||
|
.then(reload)
|
||||||
|
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (item.source_note) body.append(note(item.source_note));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const line of data.notes) body.append(note(line));
|
for (const line of data.notes) body.append(note(line));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class UnitPriceBuild:
|
|||||||
incomplete_machines: list[str] = field(default_factory=list)
|
incomplete_machines: list[str] = field(default_factory=list)
|
||||||
#: 수송비를 왜 못 세웠나 — 거리·도로 구분이 없으면 여기에 사유가 남는다(빈칸으로 안 둔다).
|
#: 수송비를 왜 못 세웠나 — 거리·도로 구분이 없으면 여기에 사유가 남는다(빈칸으로 안 둔다).
|
||||||
transport_notes: list[str] = field(default_factory=list)
|
transport_notes: list[str] = field(default_factory=list)
|
||||||
|
#: 품 할인·할증(1-4) — 고른 것이 없으면 0 이고 줄도 안 선다.
|
||||||
|
labor_surcharge_percent: Decimal = _ZERO
|
||||||
|
labor_surcharge_reasons: list[str] = field(default_factory=list)
|
||||||
#: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구.
|
#: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구.
|
||||||
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
|
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
|
||||||
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
|
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
|
||||||
@@ -547,6 +550,7 @@ def build_unit_prices(
|
|||||||
fuel_region: str | None = None,
|
fuel_region: str | None = None,
|
||||||
transport_distance_km: Decimal | None = None,
|
transport_distance_km: Decimal | None = None,
|
||||||
transport_road: str | None = None,
|
transport_road: str | None = None,
|
||||||
|
labor_surcharge_choices: dict[str, str] | None = None,
|
||||||
) -> UnitPriceBuild:
|
) -> UnitPriceBuild:
|
||||||
"""자원 축을 일위대가(`B`)로 조립한다.
|
"""자원 축을 일위대가(`B`)로 조립한다.
|
||||||
|
|
||||||
@@ -563,6 +567,9 @@ def build_unit_prices(
|
|||||||
|
|
||||||
⚠ `transport_*` — 기계 수송비(산림품셈 10-4 · 건설품셈 8-1-6의 2). **거리·도로 구분이
|
⚠ `transport_*` — 기계 수송비(산림품셈 10-4 · 건설품셈 8-1-6의 2). **거리·도로 구분이
|
||||||
없으면 그 줄이 안 선다** — 임의 거리로 금액을 세우지 않는다.
|
없으면 그 줄이 안 선다** — 임의 거리로 금액을 세우지 않는다.
|
||||||
|
|
||||||
|
⚠ `labor_surcharge_choices` — 품의 할인·할증 26계열(산림품셈 1-4). **안 고르면 안 붙는다.**
|
||||||
|
어느 공종에 붙일지는 원문 [주]가 작업을 지목하므로 **켜는 것은 사용자 몫**이다.
|
||||||
"""
|
"""
|
||||||
from B09_Estimation.B09_Estimation_FactorChoices import (
|
from B09_Estimation.B09_Estimation_FactorChoices import (
|
||||||
chosen_values,
|
chosen_values,
|
||||||
@@ -588,7 +595,17 @@ def build_unit_prices(
|
|||||||
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
|
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
|
||||||
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
|
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import COMBINE_NOTE as LABOR_SURCHARGE_COMBINE
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import SEAT_NOTE as LABOR_SURCHARGE_SEAT
|
||||||
|
from B09_Estimation.B09_Estimation_LaborSurcharge import total_percent as _labor_surcharge_total
|
||||||
|
|
||||||
|
labor_surcharge_percent, labor_surcharge_reasons = _labor_surcharge_total(
|
||||||
|
labor_surcharge_choices or {}
|
||||||
|
)
|
||||||
|
|
||||||
build = UnitPriceBuild()
|
build = UnitPriceBuild()
|
||||||
|
build.labor_surcharge_percent = labor_surcharge_percent
|
||||||
|
build.labor_surcharge_reasons = list(labor_surcharge_reasons)
|
||||||
build.factor_sources = dict(borrow_note)
|
build.factor_sources = dict(borrow_note)
|
||||||
for failed_code, why in borrow_fail.items():
|
for failed_code, why in borrow_fail.items():
|
||||||
build.factor_sources.setdefault(failed_code, f"⚠ {why}")
|
build.factor_sources.setdefault(failed_code, f"⚠ {why}")
|
||||||
@@ -762,6 +779,25 @@ def build_unit_prices(
|
|||||||
share = _share_of(row)
|
share = _share_of(row)
|
||||||
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
|
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
|
||||||
|
|
||||||
|
# 품의 할인·할증(산림품셈 1-4) — **품이 늘어난 것**이라 노무비로 붙고, 아래 제잡비의
|
||||||
|
# 밑수에도 든다. 그래서 **제잡비보다 먼저** 넣는다(줄 차례가 곧 셈 차례다).
|
||||||
|
# ⚠ 한 계열도 안 고르면 이 줄이 안 선다 — 금액이 한 원도 안 움직인다.
|
||||||
|
if labor_surcharge_percent and not variant_key.startswith("__"):
|
||||||
|
build.book.add_detail(
|
||||||
|
PriceDetail(
|
||||||
|
title_code,
|
||||||
|
title_code,
|
||||||
|
_ZERO,
|
||||||
|
note=(
|
||||||
|
f"품 할인·할증 {labor_surcharge_percent:g}% — "
|
||||||
|
+ " · ".join(labor_surcharge_reasons)
|
||||||
|
+ f" · {LABOR_SURCHARGE_SEAT} {LABOR_SURCHARGE_COMBINE}"
|
||||||
|
),
|
||||||
|
percent_of_labor=labor_surcharge_percent,
|
||||||
|
percent_of_labor_target="labor",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③).
|
# 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③).
|
||||||
# ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면
|
# ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면
|
||||||
# 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다.
|
# 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다.
|
||||||
@@ -1007,6 +1043,7 @@ def cached_build(
|
|||||||
fuel_region: str = "",
|
fuel_region: str = "",
|
||||||
transport_distance_km: str = "",
|
transport_distance_km: str = "",
|
||||||
transport_road: str = "",
|
transport_road: str = "",
|
||||||
|
labor_surcharge: tuple[tuple[str, str], ...] = (),
|
||||||
) -> UnitPriceBuild:
|
) -> UnitPriceBuild:
|
||||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
||||||
|
|
||||||
@@ -1032,6 +1069,7 @@ def cached_build(
|
|||||||
fuel_region=fuel_region or None,
|
fuel_region=fuel_region or None,
|
||||||
transport_distance_km=parse_distance_km(transport_distance_km),
|
transport_distance_km=parse_distance_km(transport_distance_km),
|
||||||
transport_road=transport_road or None,
|
transport_road=transport_road or None,
|
||||||
|
labor_surcharge_choices=dict(labor_surcharge),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""품의 할인·할증 26계열 (산림품셈 1-4) — 2026-09-09 밤.
|
||||||
|
|
||||||
|
한 계열도 안 붙고 있었다. **붙일 자리를 잘못 고르면 그것이 곧 이중계상**이라 자리부터 가렸다.
|
||||||
|
|
||||||
|
붙는 자리 = **일위대가의 품(인력) 줄**
|
||||||
|
⚠ B08 물량에 곱하면 자재·기계까지 함께 부푼다.
|
||||||
|
근거 — 원문 제목이 「1-4. **품**의 할인·할증」 · 1-6 라 표가 직접노무비를 품셈으로 냄.
|
||||||
|
|
||||||
|
⚠ 겨누는 것 여섯
|
||||||
|
① **안 고르면 한 원도 안 움직인다**
|
||||||
|
② 고르면 **노무비만** 커진다 — 재료비·경비는 그대로
|
||||||
|
③ 밑수는 **사람 품만** — 기계 줄 안의 조종원 노임은 안 센다(제잡비와 같은 규칙)
|
||||||
|
④ 제잡비가 **할증 뒤 노무비**를 밑수로 삼는다(줄 차례가 곧 셈 차례)
|
||||||
|
⑤ 원문에 없는 선택지는 안 받는다
|
||||||
|
⑥ 합산·곱 셈법이 **한 곳에서** 갈린다(원문이 안 정한 자리)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 import B09_Estimation_LaborSurcharge as LS # noqa: E402
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
|
||||||
|
PriceBook,
|
||||||
|
PriceDetail,
|
||||||
|
PriceKind,
|
||||||
|
PriceTitle,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402
|
||||||
|
|
||||||
|
_CODE = "B-FP-09-13-09" # 인력 품이 있는 공종(구조물터파기 암절취 2~3m)
|
||||||
|
|
||||||
|
|
||||||
|
def test_원문_행을_그대로_선택지로_낸다() -> None:
|
||||||
|
series = LS.load_series()
|
||||||
|
assert len(series) == 24, "율 표는 24개다(1-4-24·1-4-26 은 성격이 다름)"
|
||||||
|
first = next(item for item in series if item["key"] == "1-4-1")
|
||||||
|
assert [str(option["percent"]) for option in first["options"]] == ["10", "5", "0"]
|
||||||
|
# [주] 원문이 함께 실려야 「어느 작업에 쓰는 표인지」가 화면에 보인다.
|
||||||
|
assert "어린나무가꾸기" in first["source_note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_원문에_없는_선택지는_안_받는다() -> None:
|
||||||
|
assert LS.parse_choices({"1-4-1": "1-4-1:0"}) == {"1-4-1": "1-4-1:0"}
|
||||||
|
assert LS.parse_choices({"1-4-1": "없는값"}) == {}
|
||||||
|
# 계열과 선택지가 어긋나도 안 받는다.
|
||||||
|
assert LS.parse_choices({"1-4-2": "1-4-1:0"}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_셈법은_한_곳에서_갈린다(monkeypatch) -> None:
|
||||||
|
picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5%
|
||||||
|
assert LS.total_percent(picks)[0] == Decimal(15)
|
||||||
|
monkeypatch.setattr(LS, "COMBINE_RULE", "product")
|
||||||
|
assert LS.total_percent(picks)[0] == Decimal("15.5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_안_고르면_한_원도_안_움직인다() -> None:
|
||||||
|
base = build_unit_prices().book.resolve(_CODE)
|
||||||
|
same = build_unit_prices(labor_surcharge_choices={}).book.resolve(_CODE)
|
||||||
|
assert base.total == same.total
|
||||||
|
|
||||||
|
|
||||||
|
def test_고르면_노무비만_커진다() -> None:
|
||||||
|
before = build_unit_prices().book.resolve(_CODE)
|
||||||
|
after = build_unit_prices(
|
||||||
|
labor_surcharge_choices={"1-4-1": "1-4-1:0"} # 10%
|
||||||
|
).book.resolve(_CODE)
|
||||||
|
assert after.material == before.material
|
||||||
|
assert after.expense == before.expense
|
||||||
|
assert after.labor > before.labor
|
||||||
|
|
||||||
|
|
||||||
|
def test_밑수는_사람_품만이다() -> None:
|
||||||
|
"""③ 기계 줄 안의 조종원 노임까지 세면 할증이 부풀어 붙는다."""
|
||||||
|
book = PriceBook()
|
||||||
|
book.add_title(PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[Decimal(100_000)] * 6))
|
||||||
|
book.add_title(PriceTitle("S-1", PriceKind.MACHINE_BASE, "기계", slots=[Decimal(1)] * 6))
|
||||||
|
book.add_title(PriceTitle("X-1", PriceKind.MACHINE_HOURLY, "기계 사용료"))
|
||||||
|
book.add_detail(PriceDetail("X-1", "L-1", Decimal(1))) # 기계 층 안의 조종원
|
||||||
|
book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "시험공종"))
|
||||||
|
book.add_detail(PriceDetail("B-1", "L-1", Decimal(1)))
|
||||||
|
book.add_detail(PriceDetail("B-1", "X-1", Decimal(1)))
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(
|
||||||
|
"B-1", "B-1", Decimal(0), percent_of_labor=Decimal(10), percent_of_labor_target="labor"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 사람 품 100,000 의 10% 만 붙어야 한다(기계 층 안의 100,000 은 안 셈).
|
||||||
|
assert book.resolve("B-1").labor == Decimal(210_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_제잡비는_할증_뒤_노무비를_본다() -> None:
|
||||||
|
"""④ 품이 늘면 그 품에 비례하는 제잡비도 함께 는다."""
|
||||||
|
book = PriceBook()
|
||||||
|
book.add_title(PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[Decimal(100_000)] * 6))
|
||||||
|
book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "시험공종"))
|
||||||
|
book.add_detail(PriceDetail("B-1", "L-1", Decimal(1)))
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(
|
||||||
|
"B-1", "B-1", Decimal(0), percent_of_labor=Decimal(10), percent_of_labor_target="labor"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
book.add_detail(PriceDetail("B-1", "B-1", Decimal(0), percent_of_labor=Decimal(5)))
|
||||||
|
money = book.resolve("B-1")
|
||||||
|
assert money.labor == Decimal(110_000)
|
||||||
|
assert money.expense == Decimal(110_000) * Decimal(5) / Decimal(100)
|
||||||
Reference in New Issue
Block a user