feat(b09): 예취기·분무기 도구 줄 표 넷이 섬 — 6-2-2 줄베기·6-2-3 모두베기 갈래 · 6-4-1 덩굴걷기 · 6-4-2 덩굴 약제 · 예취기 휘발유 5.0ℓ·잡품 10%·손료 칸 · 분무기 손료 칸 · 조림목 본수 칸(비면 묘목찾기 합산 못 해 일부만 · 제안값 없음) · 마스터 갈래 재생성
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -86,6 +86,7 @@ def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
|
||||
("transport_road", "수송 도로 구분"),
|
||||
("transport_trips", "기계 수송 회수(대수 × 왕복)"),
|
||||
("seedlings_per_ha", "조림목 본수(본/ha · 풀베기 묘목찾기)"),
|
||||
):
|
||||
value = str(picked.get(key) or "").strip()
|
||||
if value:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""B09 원가계산 — **풀베기 묘목찾기 = 조림목 본수 칸** (2026-09-15 브레인 차례 ② 판정 ㉠ · 지금 칸을 만듦).
|
||||
|
||||
산림 6-2-2 줄베기 · 6-2-3 모두베기 [주]④⑤ 「묘목찾기와 줄베기(모두베기) 품을 **합하여** 적용」 — 묘목찾기는
|
||||
「낫 | 인/100본 | 0.07 | 보통인부」 라 조림목 본수(본/ha · [주]② 둘레베기 참고 · 설계 입력)가 있어야 ha당이 섬.
|
||||
⇒ 본수가 들면 갈래마다 보통인부 0.07 × 본수 ÷ 100 · 비면 「일부만」 으로 막고 사유(반만 선 틀린 값보다 안 선 값).
|
||||
⚠ 제안값 없음 — 부록 예시 2,700본/ha 는 같은 부록이 품도 표와 다르게 적은 자료(묘목찾기 0.10 ↔ 표 0.07).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
#: 공종 → 표 번호(`_JudgedTable` 도구 줄 표 · 인/100본 줄이 있는 둘).
|
||||
SEEDLING_TABLES = {"FP-06-02-02": "F0155", "FP-06-02-03": "F0156"}
|
||||
NAME = "묘목찾기"
|
||||
MISSING = (
|
||||
"조림목 본수(본/ha) 미입력 — 원문 [주]④⑤ 가 묘목찾기({amount}인/100본)와 합하여 적용하라 함"
|
||||
" · 「산출 조건」 에서 넣으면 섬"
|
||||
)
|
||||
|
||||
|
||||
def parse_seedlings_per_ha(text: str | None) -> Decimal | None:
|
||||
"""조림목 본수 칸 — 빈 칸은 `None` · 0 이하·수가 아닌 값은 `ValueError`(조용히 접지 않음)."""
|
||||
cleaned = re.sub(r"[\s,]", "", str(text or ""))
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
value = Decimal(cleaned)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"조림목 본수는 수로 넣어야 합니다: {text}") from exc
|
||||
if value <= 0:
|
||||
raise ValueError(f"조림목 본수는 0 보다 커야 합니다: {text}")
|
||||
return value
|
||||
|
||||
|
||||
def _per_hundred(node: dict[str, Any], table_id: str) -> tuple[Decimal, str] | None:
|
||||
"""(인/100본 값, 인력 이름) — 표에서 읽음. 칸이 달라지면 `None`."""
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
|
||||
|
||||
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
|
||||
for row in table.get("raw_row") or []:
|
||||
cells = ["".join(str(c).split()) for c in row]
|
||||
if "인/100본" in cells:
|
||||
unit = cells.index("인/100본")
|
||||
amount = parse_amount(cells[unit + 1]) if len(cells) > unit + 2 else None
|
||||
if amount is not None and cells[unit + 2]:
|
||||
return amount, cells[unit + 2]
|
||||
return None
|
||||
|
||||
|
||||
def attach_seedling_finding(
|
||||
build: Any, nodes: dict[str, dict[str, Any]], seedlings_per_ha: Decimal | None
|
||||
) -> None:
|
||||
"""본수가 들면 갈래 제목마다 묘목찾기 인력을 붙이고, 없으면 일부만 + 사유."""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind
|
||||
|
||||
book = build.book
|
||||
for code, table_id in SEEDLING_TABLES.items():
|
||||
found = _per_hundred(nodes.get(code) or {}, table_id)
|
||||
titles = [t for t in book.titles if t.startswith(f"B-{code}#")]
|
||||
labels = [
|
||||
label for label in build.unattached.get(code) or [] if "".join(label.split()) != NAME
|
||||
]
|
||||
labor = next(
|
||||
(
|
||||
c
|
||||
for c, t in book.titles.items()
|
||||
if found and t.kind is PriceKind.LABOR and "#" not in c and t.name == found[1]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if found is None or labor is None or not titles:
|
||||
build.component_gaps[code] = "묘목찾기 줄 칸이 달라져 못 읽음"
|
||||
build.partial_ratio.setdefault(code, Decimal(0))
|
||||
continue
|
||||
amount, name = found
|
||||
if seedlings_per_ha is None:
|
||||
reason = MISSING.format(amount=amount)
|
||||
build.component_gaps[code] = reason
|
||||
build.partial_ratio.setdefault(code, Decimal(0))
|
||||
labels.append(reason)
|
||||
else:
|
||||
note = f"묘목찾기 {amount}인/100본 × 조림목 {seedlings_per_ha}본/ha ÷ 100 — [주]④⑤ 합하여 적용"
|
||||
for title in titles:
|
||||
book.add_detail(
|
||||
PriceDetail(title, labor, amount * seedlings_per_ha / 100, note=note)
|
||||
)
|
||||
build.unattached[code] = labels
|
||||
@@ -26,11 +26,14 @@ class Equipment:
|
||||
key: str
|
||||
machine: str # AR-X — 1대 1일 호표
|
||||
price: str # AR-M — 구입가(손료 밑수) 칸
|
||||
fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름)
|
||||
#: (2-1 공종, 표, 주연료 줄 이름) · 연료 없는 장비(배부식분무기)는 None
|
||||
fuel: tuple[str, str, str] | None
|
||||
loss: tuple[str, str] # (2-2 공종, 표)
|
||||
choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름)
|
||||
users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름
|
||||
basis: str = ""
|
||||
#: 도구 줄 표(6-2·6-4 「사용도구 | … | 인력구분」)의 도구 칸 이름 — 그 줄의 인력이 쓰는 사람.
|
||||
tool: str = ""
|
||||
|
||||
|
||||
EQUIPMENTS: tuple[Equipment, ...] = (
|
||||
@@ -49,6 +52,29 @@ EQUIPMENTS: tuple[Equipment, ...] = (
|
||||
users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"},
|
||||
basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료",
|
||||
),
|
||||
# 도구 줄 표 넷(2026-09-15 브레인 ②) — 2-1-1 2 [주]① 「재료비는 예취기 작업(줄베기, 모두베기, 지상부
|
||||
# 덩굴걷기)에만」 · 「1대당 1인 작업」 · 2-2-2 손료.
|
||||
Equipment(
|
||||
key="예취기",
|
||||
machine="AR-X-da716e54",
|
||||
price="AR-M-148c5888",
|
||||
fuel=("FP-02-01-01", "F0043", "예취기(휘발유)"),
|
||||
loss=("FP-02-02-02", "F0065"),
|
||||
users={"FP-06-02-02": "특별인부", "FP-06-02-03": "특별인부", "FP-06-04-01": "특별인부"},
|
||||
basis="산림품셈 2-1-1 2 예취기 「1대당 1인 작업」 · 2-2-2 손료",
|
||||
tool="예취기",
|
||||
),
|
||||
# 2-2-4 배부식분무기(덩굴 약제처리) 손료만 — 2-1 에 연료 표 없음(사람이 멤).
|
||||
Equipment(
|
||||
key="배부식분무기",
|
||||
machine="AR-X-5d0a0416",
|
||||
price="AR-M-d6394831",
|
||||
fuel=None,
|
||||
loss=("FP-02-02-04", "F0067"),
|
||||
users={"FP-06-04-02": "특별인부"},
|
||||
basis="산림품셈 2-2-4 배부식분무기 「1대당 1인 작업」 손료",
|
||||
tool="배부식분무기",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -143,20 +169,24 @@ def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | No
|
||||
|
||||
book = build.book
|
||||
code = f"X-{eq.machine}"
|
||||
fuel = _fuel_values(_table_rows(nodes, eq.fuel[0], eq.fuel[1]), eq.fuel[2])
|
||||
fuel = _fuel_values(_table_rows(nodes, eq.fuel[0], eq.fuel[1]), eq.fuel[2]) if eq.fuel else None
|
||||
loss_rows = _table_rows(nodes, *eq.loss)
|
||||
loss = _row_numbers(loss_rows, loss_rows[0][0]) if loss_rows else []
|
||||
if fuel is None or not loss:
|
||||
if (eq.fuel and fuel is None) or not loss:
|
||||
return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"]
|
||||
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
|
||||
liters, misc = fuel
|
||||
if code in book.titles:
|
||||
return [] if eq.price in book.titles else [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸"]
|
||||
if not eq.fuel and eq.price not in book.titles:
|
||||
# 연료 없는 장비는 손료가 전부 — 구입가가 없으면 빈 호표가 되어 제목을 안 세움(칸 사유만).
|
||||
return [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음"]
|
||||
book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일"))
|
||||
book.add_detail(
|
||||
PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}")
|
||||
)
|
||||
book.add_detail(_misc_row(code, misc))
|
||||
if eq.fuel and fuel is not None:
|
||||
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
|
||||
liters, misc = fuel
|
||||
book.add_detail(
|
||||
PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}")
|
||||
)
|
||||
book.add_detail(_misc_row(code, misc))
|
||||
reasons = []
|
||||
if eq.price in book.titles:
|
||||
base = f"S-{eq.machine}"
|
||||
@@ -184,7 +214,7 @@ def attach_consumables(
|
||||
uses = build.material_uses.setdefault(material, [])
|
||||
uses.extend(c for c in eq.users if c not in uses)
|
||||
machine_reasons = _machine_title(build, nodes, eq, fuel_region)
|
||||
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
|
||||
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) if eq.fuel else []
|
||||
picked = [(c, name) for c, name in eq.choices if c in book.titles]
|
||||
for work_item, user_row in eq.users.items():
|
||||
node_rows = [
|
||||
@@ -193,8 +223,18 @@ def attach_consumables(
|
||||
for r in t.get("raw_row") or []
|
||||
]
|
||||
# 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③).
|
||||
# 도구 줄 표는 한 줄에 도구와 인력이 함께 — 그 도구 줄이 그 인력을 밝힐 때만(③).
|
||||
if not any(
|
||||
r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split())
|
||||
r
|
||||
and (
|
||||
_tight(r[0]) == _tight(user_row)
|
||||
or user_row in str(r[0]).split()
|
||||
or (
|
||||
eq.tool
|
||||
and _tight(eq.tool) in {_tight(c) for c in r}
|
||||
and _tight(user_row) in {_tight(c) for c in r}
|
||||
)
|
||||
)
|
||||
for r in node_rows
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -123,6 +123,16 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
||||
" 를 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣음 · 물탱크 조종원은 화물차운전사(8-1-2 5호"
|
||||
" 살수차) · [주]⑦ 물주기(인력) 보통인부 0.0005인은 필요시라 안 걺(2026-09-15 브레인).",
|
||||
),
|
||||
"FP-06-02-02": (
|
||||
"부록 예시와 다름",
|
||||
"ⓘ 부록 단가산출서 예시(2025 · 조림목 2,700본/ha)는 묘목찾기 0.10인/100본 · 줄베기 1.40인/ha 로 적어"
|
||||
" 본문 표(묘목찾기 0.07 · 줄베기 1.50~2.50)와 다름 — 본문 표로 셈(기록만 · 2026-09-15 브레인).",
|
||||
),
|
||||
"FP-06-02-03": (
|
||||
"부록 예시와 다름",
|
||||
"ⓘ 부록 단가산출서 예시(2025 · 조림목 2,700본/ha)는 묘목찾기 0.10인/100본 · 모두베기 3.10인/ha 로 적어"
|
||||
" 본문 표(묘목찾기 0.07 · 모두베기 3.50~5.00)와 다름 — 본문 표로 셈(기록만 · 2026-09-15 브레인).",
|
||||
),
|
||||
"FP-12-25": (
|
||||
"운반거리 미정",
|
||||
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
|
||||
|
||||
@@ -113,7 +113,37 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
||||
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
|
||||
"why": "원문 L7809 14-2 「0.8㎥ 굴착기 h · 보통인부 h」(10본당)",
|
||||
},
|
||||
# 도구 줄 표 — 「공법 | (갈래) | 사용도구 | 단위 | 소요인력 | 인력구분」 · 이름이 끝 칸(2026-09-15 브레인 ②).
|
||||
# `variants` 면 단위 칸 앞 마지막 칸이 갈래 · 「인/100본」 줄(묘목찾기)은 본수 칸이 들 때 붙음(`_Brushcutting`).
|
||||
"F0155": {
|
||||
"code": "FP-06-02-02",
|
||||
"shape": "tool_rows",
|
||||
"prefix": "줄베기",
|
||||
"variants": True,
|
||||
"why": "원문 6-2-2 「묘목찾기 낫 인/100본 · 줄베기 본수 구간 예취기 인/ha · 인력구분」",
|
||||
},
|
||||
"F0156": {
|
||||
"code": "FP-06-02-03",
|
||||
"shape": "tool_rows",
|
||||
"prefix": "모두베기",
|
||||
"variants": True,
|
||||
"why": "원문 6-2-3 「묘목찾기 낫 인/100본 · 모두베기 조림 경과 예취기 인/ha · 인력구분」",
|
||||
},
|
||||
"F0158": {
|
||||
"code": "FP-06-04-01",
|
||||
"shape": "tool_rows",
|
||||
"prefix": "덩굴걷기",
|
||||
"why": "원문 6-4-1 「기계작업 예취기 인/ha 3.30 특별인부」",
|
||||
},
|
||||
"F0159": {
|
||||
"code": "FP-06-04-02",
|
||||
"shape": "tool_rows",
|
||||
"prefix": "덩굴 약제 살포처리",
|
||||
"why": "원문 6-4-2 「약제살포 배부식분무기 인/ha · 작업보조 인/ha · 인력구분」",
|
||||
},
|
||||
}
|
||||
#: 도구 줄 표의 단위 칸 — 「인/ha」 는 공종 단위 ha 로 곧장 · 「인/100본」 은 본수 칸이 들어야 섬.
|
||||
_RE_TOOL_UNIT = re.compile(r"^인/(ha|100본)$")
|
||||
#: 총칙 L530 「본 품셈에서 제시된 품은 일일 작업시간 8시간을 기준」 — 인력 시간 ÷ 8 = 인.
|
||||
HOURS_PER_DAY = Decimal(8)
|
||||
_RE_SPEC_FIRST_MACHINE = re.compile(r"^\d+(?:\.\d+)?㎥굴착기")
|
||||
@@ -218,7 +248,11 @@ def match_judged_table(
|
||||
return True
|
||||
|
||||
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
|
||||
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
|
||||
if basis_quantity in (None, 0) and judged["shape"] not in (
|
||||
"remark_labor",
|
||||
"per_m3_rows",
|
||||
"tool_rows", # 줄마다 단위 칸(인/ha)이 밑수
|
||||
):
|
||||
return block("판정 표에 밑수가 없습니다")
|
||||
if judged["shape"] == "header_row":
|
||||
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||
@@ -230,6 +264,8 @@ def match_judged_table(
|
||||
staged = _hour_rows(code, table, judged, rows, catalog, basis_quantity)
|
||||
elif judged["shape"] == "per_m3_rows":
|
||||
staged = _per_m3_rows(code, table, judged, rows, catalog)
|
||||
elif judged["shape"] == "tool_rows":
|
||||
staged = _tool_rows(code, table, judged, rows, catalog)
|
||||
else:
|
||||
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||
if isinstance(staged, str):
|
||||
@@ -435,3 +471,39 @@ def _hour_rows(code, table, judged, rows, catalog, basis_quantity) -> list | str
|
||||
for index, entry, amount in labor:
|
||||
staged.append(_row(code, table, entry, amount / basis_quantity, unit, index, form))
|
||||
return staged
|
||||
|
||||
|
||||
#: 「인/100본」 줄 사유 — 본수 칸이 들면 `_Brushcutting` 이 걷고 붙임.
|
||||
PER_HUNDRED_REASON = "조림목 본수(본/ha) 칸이 들어야 붙음 — 단위가 인/100본"
|
||||
|
||||
|
||||
def _tool_rows(code, table, judged, rows, catalog) -> list | str:
|
||||
"""단위 칸(인/ha · 인/100본)을 찾아 뒤 칸 = 값 · 그 뒤 = 인력 · `variants` 면 단위 앞 앞 칸이 갈래.
|
||||
|
||||
⚠ 「인/100본」 줄은 여기서 안 셈 — 본수가 설계 입력이라 자원 축(프로젝트 밖)에선 모름 → 못 붙은 줄로.
|
||||
"""
|
||||
staged: list = []
|
||||
per_ha = 0
|
||||
for index, cells in enumerate(rows):
|
||||
unit = next(
|
||||
(i for i, c in enumerate(cells) if _RE_TOOL_UNIT.match("".join(c.split()))), None
|
||||
)
|
||||
if unit is None or unit < 1 or len(cells) < unit + 3:
|
||||
continue
|
||||
amount = parse_amount(cells[unit + 1])
|
||||
entry = _entry(catalog, cells[unit + 2], code)
|
||||
if amount is None or entry is None:
|
||||
return f"{cells[0]} 줄"
|
||||
if "".join(cells[unit].split()) == "인/100본":
|
||||
staged.append(
|
||||
UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], PER_HUNDRED_REASON)
|
||||
)
|
||||
continue
|
||||
variant = ""
|
||||
if judged.get("variants"):
|
||||
if unit < 2:
|
||||
return f"{index}째 줄 갈래 칸"
|
||||
variant = cells[unit - 2]
|
||||
staged.append(_row(code, table, entry, amount, "ha", index, variant))
|
||||
per_ha += 1
|
||||
return staged if per_ha else "인/ha 줄"
|
||||
|
||||
@@ -292,6 +292,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()):
|
||||
tuple(sorted(set(dump_haul_m), key=Decimal)),
|
||||
# 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 조립에 얹음. 없으면 종전 벌 그대로.
|
||||
material_prices_key(settings.get(MATERIAL_PRICES_KEY)),
|
||||
# 조림목 본수(본/ha) — 풀베기 묘목찾기 합산(6-2-2·6-2-3 [주]④⑤). 비면 그 둘은 일부만.
|
||||
str(settings.get("seedlings_per_ha") or ""),
|
||||
)
|
||||
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
|
||||
return edited_build(args, edits_key(settings.get("edits")))
|
||||
|
||||
@@ -202,6 +202,16 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
||||
"basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION],
|
||||
"notes": transport_notes,
|
||||
},
|
||||
"seedlings": {
|
||||
"per_ha": str(settings.get("seedlings_per_ha") or ""),
|
||||
"basis": [
|
||||
"산림품셈 6-2-2 줄베기 · 6-2-3 모두베기 [주]④⑤ — 「묘목찾기와 줄베기(모두베기) 품을"
|
||||
" 합하여 적용」 · 묘목찾기 0.07인/100본 × 조림목 본수 ÷ 100 · 본수는 [주]② 둘레베기 참고"
|
||||
"(표준지로 조사한 조림목 생육본수)",
|
||||
"⚠ 비워 두면 줄베기·모두베기 단가가 「일부만」 으로 막힙니다 — 묘목찾기가 빠진 값은"
|
||||
" 원문보다 모자라서입니다. 제안값은 두지 않습니다.",
|
||||
],
|
||||
},
|
||||
"labor_surcharge": {
|
||||
"chosen": labor_surcharge_chosen,
|
||||
"total_percent": f"{labor_surcharge_total:g}",
|
||||
@@ -253,6 +263,8 @@ class FactorChoiceBody(BaseModel):
|
||||
transport_trips: str | None = None
|
||||
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
|
||||
labor_surcharge: dict[str, str] | None = None
|
||||
#: 조림목 본수(본/ha) — 풀베기 묘목찾기 합산. **빈 문자열이면 줄베기·모두베기가 일부만으로 막힘.**
|
||||
seedlings_per_ha: str | None = None
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation/factors")
|
||||
@@ -335,6 +347,14 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
values["transport_trips"] = "" if trips is None else str(trips)
|
||||
if body.seedlings_per_ha is not None:
|
||||
from B09_Estimation.B09_Estimation_Brushcutting import parse_seedlings_per_ha
|
||||
|
||||
try:
|
||||
seedlings = parse_seedlings_per_ha(body.seedlings_per_ha)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
values["seedlings_per_ha"] = "" if seedlings is None else str(seedlings)
|
||||
if body.labor_surcharge is not None:
|
||||
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
|
||||
|
||||
|
||||
@@ -80,11 +80,18 @@ interface LaborSurchargeRow {
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
/** 조림목 본수(본/ha) — 풀베기 묘목찾기 합산(산림품셈 6-2-2·6-2-3 [주]④⑤). 비면 일부만. */
|
||||
interface SeedlingsRow {
|
||||
per_ha: string;
|
||||
basis: string[];
|
||||
}
|
||||
|
||||
export interface FactorChoicesDto {
|
||||
ranges: RangeFactorRow[];
|
||||
machines: MachineChoiceRow[];
|
||||
misc_material?: MiscMaterialRow;
|
||||
transport?: TransportRow;
|
||||
seedlings?: SeedlingsRow;
|
||||
labor_surcharge?: LaborSurchargeRow;
|
||||
notes: string[];
|
||||
}
|
||||
@@ -109,6 +116,7 @@ export async function saveFactorChoices(
|
||||
transport_road?: string;
|
||||
transport_trips?: string;
|
||||
labor_surcharge?: Record<string, string>;
|
||||
seedlings_per_ha?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
@@ -138,6 +146,7 @@ function percentBox(
|
||||
value: string,
|
||||
placeholder: string,
|
||||
onApply: (text: string) => void,
|
||||
unit = "%",
|
||||
): HTMLElement {
|
||||
const wrap = el("div", "b09s-hint b09s-inline");
|
||||
const input = el("input");
|
||||
@@ -150,7 +159,7 @@ function percentBox(
|
||||
const apply = el("button", "", "적용");
|
||||
apply.type = "button";
|
||||
apply.addEventListener("click", () => onApply(input.value.trim()));
|
||||
wrap.append(el("span", "b09s-head", label), input, el("span", "", "%"), apply);
|
||||
wrap.append(el("span", "b09s-head", label), input, el("span", "", unit), apply);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -275,6 +284,20 @@ export function drawFactorChoices(
|
||||
body.append(note(transport.trips_note));
|
||||
}
|
||||
|
||||
const seedlings = data.seedlings;
|
||||
if (seedlings) {
|
||||
body.append(
|
||||
percentBox(
|
||||
"조림목 본수 (풀베기 묘목찾기)",
|
||||
seedlings.per_ha,
|
||||
"비움",
|
||||
(text) => save({ seedlings_per_ha: text }),
|
||||
"본/ha",
|
||||
),
|
||||
);
|
||||
for (const line of seedlings.basis) body.append(note(line));
|
||||
}
|
||||
|
||||
const surcharge = data.labor_surcharge;
|
||||
if (surcharge) {
|
||||
body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다"));
|
||||
|
||||
@@ -70,6 +70,7 @@ from B09_Estimation.B09_Estimation_WorkItemUnit import BORROWED_BASIS_PER
|
||||
from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
|
||||
from B09_Estimation.B09_Estimation_Brushcutting import parse_seedlings_per_ha
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
#: 조종원 **시간당 노임** 제목 꼬리 — 일 노임 제목(`L…`)과 갈라 둠. 노무비목록표엔 안 실림(일당만).
|
||||
@@ -645,6 +646,7 @@ def build_unit_prices(
|
||||
operator_wage_digits: int = 0,
|
||||
dump_haul_m: tuple[Decimal, ...] = (),
|
||||
material_prices: tuple[tuple[str, str, str], ...] = (),
|
||||
seedlings_per_ha: Decimal | None = None,
|
||||
) -> UnitPriceBuild:
|
||||
"""자원 축을 일위대가(`B`)로 조립한다.
|
||||
|
||||
@@ -918,7 +920,11 @@ def build_unit_prices(
|
||||
for row in rows:
|
||||
section = missing_basis.get(str(row.pum_table_id))
|
||||
# 비고가 「인/㎥」 로 밑수를 적은 판정표(12-12 날개벽 「개소당」 머리 없음)는 밑수가 ㎥ 로 섬.
|
||||
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") == "remark_labor"
|
||||
# 도구 줄 표(6-2·6-4)는 줄마다 단위 칸 「인/ha」 가 밑수(2026-09-15 브레인 ②).
|
||||
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") in (
|
||||
"remark_labor",
|
||||
"tool_rows",
|
||||
)
|
||||
if section and not borrowed and not per_remark:
|
||||
build.basis_missing[work_item_code] = section
|
||||
break
|
||||
@@ -1125,6 +1131,10 @@ def build_unit_prices(
|
||||
from B09_Estimation.B09_Estimation_Consumables import attach_consumables
|
||||
|
||||
attach_consumables(build, nodes_by_code, fuel_region)
|
||||
# 풀베기 묘목찾기(6-2-2·6-2-3 [주]④⑤ 합하여 적용) — 조림목 본수 칸 · 비면 일부만(2026-09-15 브레인 ②).
|
||||
from B09_Estimation.B09_Estimation_Brushcutting import attach_seedling_finding
|
||||
|
||||
attach_seedling_finding(build, nodes_by_code, seedlings_per_ha)
|
||||
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
||||
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
||||
build.combined_swapped = _apply_combined_misc_rate(
|
||||
@@ -1277,6 +1287,7 @@ def cached_build(
|
||||
operator_wage_digits: str = "",
|
||||
dump_haul_m: tuple[str, ...] = (),
|
||||
material_prices: tuple[tuple[str, str, str], ...] = (),
|
||||
seedlings_per_ha: str = "",
|
||||
) -> UnitPriceBuild:
|
||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
||||
|
||||
@@ -1306,6 +1317,7 @@ def cached_build(
|
||||
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
|
||||
dump_haul_m=tuple(Decimal(value) for value in dump_haul_m),
|
||||
material_prices=material_prices,
|
||||
seedlings_per_ha=parse_seedlings_per_ha(seedlings_per_ha),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -409,6 +409,59 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-X-da716e54",
|
||||
"kind": "machine",
|
||||
"name": "예취기",
|
||||
"spec": "배기량 35cc",
|
||||
"unit": "대·일",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0043",
|
||||
"F0065"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-148c5888",
|
||||
"kind": "material",
|
||||
"name": "예취기 구입가",
|
||||
"spec": "배기량 35cc · 손료 밑수",
|
||||
"unit": "대",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0065"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-X-5d0a0416",
|
||||
"kind": "machine",
|
||||
"name": "배부식분무기",
|
||||
"spec": "덩굴 약제처리",
|
||||
"unit": "대·일",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0067"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-d6394831",
|
||||
"kind": "material",
|
||||
"name": "배부식분무기 구입가",
|
||||
"spec": "덩굴 약제처리 · 손료 밑수",
|
||||
"unit": "대",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0067"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-5649cf3f",
|
||||
"kind": "material",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-15T03:10:55+09:00",
|
||||
"generated_at": "2026-09-15T05:49:19+09:00",
|
||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||
"source": {
|
||||
"dataset_id": "pum_forest",
|
||||
@@ -12,8 +12,8 @@
|
||||
"files": [
|
||||
{
|
||||
"file": "work_item_master_2026-01-01.json",
|
||||
"sha256": "480f9510cdd2fe223b54818fb7180f8c6114d6e33e45cd3c6aa829c64de27eb3",
|
||||
"size_bytes": 839153
|
||||
"sha256": "2a0049e73fe48e44c79dda354ae13cf850a61227acdc22645cb94d52e89bbb2b",
|
||||
"size_bytes": 839589
|
||||
},
|
||||
{
|
||||
"file": "form_undetermined_2026-01-01.json",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
"pum_edition": "2026-01-01",
|
||||
"generated_at": "2026-09-15T03:10:55+09:00",
|
||||
"generated_at": "2026-09-15T05:49:19+09:00",
|
||||
"dataset_version": {
|
||||
"dataset_id": "pum_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
@@ -16907,7 +16907,11 @@
|
||||
"formula_rows": [],
|
||||
"special_glyphs": [],
|
||||
"capacity_formula_here": false,
|
||||
"variant_key": [],
|
||||
"variant_key": [
|
||||
"1,500본 미만",
|
||||
"1,500본 이상 3,000본 미만",
|
||||
"3,000본 이상"
|
||||
],
|
||||
"condition_note": [
|
||||
"공 법",
|
||||
"사용도구",
|
||||
@@ -16951,7 +16955,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"variant_keys": []
|
||||
"variant_keys": [
|
||||
"1,500본 미만",
|
||||
"1,500본 이상 3,000본 미만",
|
||||
"3,000본 이상"
|
||||
]
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-06-02-03",
|
||||
@@ -16978,7 +16986,11 @@
|
||||
"formula_rows": [],
|
||||
"special_glyphs": [],
|
||||
"capacity_formula_here": false,
|
||||
"variant_key": [],
|
||||
"variant_key": [
|
||||
"조림 당해 연도 (전년도 추기조림 포함)",
|
||||
"조림 2년차",
|
||||
"조림 3년차 이상"
|
||||
],
|
||||
"condition_note": [
|
||||
"공 법",
|
||||
"사용도구",
|
||||
@@ -17022,7 +17034,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"variant_keys": []
|
||||
"variant_keys": [
|
||||
"조림 당해 연도 (전년도 추기조림 포함)",
|
||||
"조림 2년차",
|
||||
"조림 3년차 이상"
|
||||
]
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-06-03",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""예취기·분무기 인력 표 한 모양 넷 — 2026-09-15 브레인 차례 ② (사유 없던 49 다음).
|
||||
|
||||
산림 6-2-2 줄베기 · 6-2-3 모두베기 · 6-4-1 덩굴걷기 · 6-4-2 덩굴 약제 표는 「공법 | (갈래) | 사용도구 | 단위 | 소요인력 |
|
||||
인력구분」 줄 — 이름이 끝 칸이라 줄 읽기가 한 줄도 못 읽었음(6-4-1 은 사유조차 없었음).
|
||||
6-2-2 묘목찾기 낫 인/100본 0.07 보통인부 · 줄베기 1,500본 미만 1.50 · 1,500~3,000 2.00 · 3,000 이상 2.50 특별인부(인/ha)
|
||||
6-2-3 묘목찾기 0.07 · 모두베기 조림 당해 연도 3.50 · 2년차 4.00 · 3년차 이상 5.00
|
||||
6-4-1 기계작업 예취기 인/ha 3.30 특별인부 · 6-4-2 약제살포 배부식분무기 1.00 특별인부 + 작업보조 1.50 보통인부
|
||||
⭐ 6-2-2·6-2-3 [주]④⑤ 「묘목찾기와 줄베기(모두베기) 품을 **합하여** 적용」 — 조림목 본수(본/ha)는 설계 입력 칸 ·
|
||||
비면 「일부만」 으로 막음(반만 선 틀린 값보다 안 선 값) · 제안값 없음(부록 예시 2,700본/ha 는 품도 표와 다른 자료)
|
||||
2장: 예취기 휘발유 5.0ℓ/대/일 · 잡품 10%(2-1-1 2 [주]① 줄베기·모두베기·지상부 덩굴걷기에만) · 손료 0.0084(2-2-2) ·
|
||||
배부식분무기 손료 0.0084(2-2-4) — 체인톱 한 벌 길 · 1대당 1인
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||
|
||||
CUTTER = "X-AR-X-da716e54"
|
||||
SPRAYER = "X-AR-X-5d0a0416"
|
||||
|
||||
|
||||
def _rows(book, title: str) -> dict[str, Decimal]:
|
||||
return {r.ref_code: r.quantity for r in book.details[title]}
|
||||
|
||||
|
||||
def test_덩굴걷기는_특별인부와_예취기_1대_1일() -> None:
|
||||
build = cached_build()
|
||||
rows = _rows(build.book, "B-FP-06-04-01")
|
||||
assert rows["1003"] == Decimal("3.30") and rows[CUTTER] == Decimal("3.30"), rows
|
||||
assert "FP-06-04-01" not in build.partial_ratio and "FP-06-04-01" not in build.basis_missing
|
||||
assert build.book.titles["B-FP-06-04-01"].unit == "ha"
|
||||
cutter = _rows(build.book, CUTTER)
|
||||
assert cutter["M-FUEL-휘발유"] == Decimal("5.0"), cutter
|
||||
misc = next(r for r in build.book.details[CUTTER] if r.percent_of_material is not None)
|
||||
assert misc.percent_of_material == Decimal(10)
|
||||
assert "예취기가격" in " ".join(build.unattached["FP-06-04-01"])
|
||||
|
||||
|
||||
def test_덩굴_약제는_두_인력을_더하고_분무기_손료_칸() -> None:
|
||||
build = cached_build()
|
||||
rows = _rows(build.book, "B-FP-06-04-02")
|
||||
assert rows["1003"] == Decimal("1.00") and rows["1002"] == Decimal("1.50"), rows
|
||||
assert SPRAYER not in rows # 연료 없는 장비 — 구입가가 없으면 빈 호표를 안 세움
|
||||
assert "배부식분무기가격" in " ".join(build.unattached["FP-06-04-02"])
|
||||
assert "FP-06-04-02" not in build.basis_missing # 단위 칸 인/ha 가 밑수
|
||||
priced = cached_build(material_prices=(("AR-M-d6394831", "150000", "견적"),))
|
||||
assert _rows(priced.book, "B-FP-06-04-02")[SPRAYER] == Decimal("1.00")
|
||||
assert priced.book.resolve(SPRAYER).expense == Decimal("1260.0000") # 150,000 × 0.0084
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "variants"),
|
||||
[
|
||||
(
|
||||
"FP-06-02-02",
|
||||
{"1,500본미만": "1.50", "1,500본이상3,000본미만": "2.00", "3,000본이상": "2.50"},
|
||||
),
|
||||
(
|
||||
"FP-06-02-03",
|
||||
{
|
||||
"조림당해연도(전년도추기조림포함)": "3.50",
|
||||
"조림2년차": "4.00",
|
||||
"조림3년차이상": "5.00",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_풀베기는_갈래마다_특별인부_예취기_묘목찾기는_본수_칸(code, variants) -> None:
|
||||
empty = cached_build()
|
||||
for key, people in variants.items():
|
||||
rows = _rows(empty.book, f"B-{code}#{key}")
|
||||
assert rows["1003"] == Decimal(people) and rows[CUTTER] == Decimal(people), rows
|
||||
assert "1002" not in rows # 본수가 없으면 묘목찾기를 안 셈
|
||||
assert code in empty.partial_ratio # 반만 선 값으로 안 붙게 막음
|
||||
assert "조림목 본수" in empty.component_gaps[code], empty.component_gaps.get(code)
|
||||
filled = cached_build(seedlings_per_ha="2700")
|
||||
for key in variants:
|
||||
rows = _rows(filled.book, f"B-{code}#{key}")
|
||||
assert rows["1002"] == Decimal("0.07") * Decimal(27), rows # 0.07인/100본 × 2,700본/ha
|
||||
assert code not in filled.partial_ratio
|
||||
assert "조림목 본수" not in (filled.component_gaps.get(code) or "")
|
||||
|
||||
|
||||
def test_본수_칸은_양수만_받음() -> None:
|
||||
from B09_Estimation.B09_Estimation_Brushcutting import parse_seedlings_per_ha
|
||||
|
||||
assert parse_seedlings_per_ha("") is None
|
||||
assert parse_seedlings_per_ha("2,700") == Decimal(2700)
|
||||
for bad in ("0", "-5", "많이"):
|
||||
with pytest.raises(ValueError):
|
||||
parse_seedlings_per_ha(bad)
|
||||
|
||||
|
||||
def test_부록_예시와_표의_어긋남은_기록만() -> None:
|
||||
note = known_gap_note("FP-06-02-02")
|
||||
assert "부록" in note and "0.10" in note and "0.07" in note, note
|
||||
assert "2,700" not in (cached_build().component_gaps.get("FP-06-02-02") or "") # 제안값 없음
|
||||
@@ -39,9 +39,10 @@ def test_제목_없는_잎_공종은_모두_사유가_있음() -> None:
|
||||
|
||||
def test_사유는_표_번호와_모양을_가리킴() -> None:
|
||||
gaps = cached_build().component_gaps
|
||||
# 6-4-1 덩굴걷기 F0158 은 예취기 도구 줄 표로 풀려 사유가 저절로 빠짐 → 6-2-1 둘레베기로 갈음
|
||||
for code, table in (
|
||||
("FP-03-01", "F0075"),
|
||||
("FP-06-04-01", "F0158"),
|
||||
("FP-06-02-01", "F0154"),
|
||||
("FP-07-03", "F0174"),
|
||||
("FP-03-08", "F0082"),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user