Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-15 06:29:45 +09:00
15 changed files with 465 additions and 65 deletions
@@ -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
+51 -11
View File
@@ -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 줄"
+2
View File
@@ -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
+24 -1
View File
@@ -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) — 고른 것만 붙습니다"));
+13 -1
View File
@@ -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),
)
-42
View File
@@ -1,42 +0,0 @@
# PLAN — 랩탑 메인 (1장 `[랩탑_메인]` 몫)
> ⚠ 임시 · 2026-09-15 시놀로지 덮어쓰기 때문 · 사용자 확인 전(브레인 지시). `PLAN.md` 는 읽기만 · 나중에 브레인이 창별 파일을 합쳐 `PLAN.md` 한 벌로 되돌림.
> git 에 명시 커밋으로 올림(PC 사이를 건너가고 이력이 남게 · 사용자 지침 「PLAN.md 는 git 밖 · 한 파일로만」 과 어긋나는 임시 조치).
## 1. 축 세우기 — 랩탑 메인 차례
### 남은 것
- [ ] ⚠ 자원 이름이 겹치면 남의 공종 풀이가 바뀜(명세 2장 이름 맞춤의 부작용) — 자원 목록에 「양수기」 를 넣자 12-26 물푸기 「양수기(150m/m)」 줄 풀이가 바뀌어 제목이 사라짐 → 지금은 「유인헬기방제 양수기」 로 피함(임시) · 언젠가 공종 범위·코드로 맞출 자리
- [ ] ㉯ 나머지 — 제목 없음(지금 셈 34 · 같은 자) 가르기: ⓐ 참고 표 19(1-2 단위표준·운반 · 2장 소요재료·기계손료 17 — 다른 공종이 읽는 표라 제목이 안 서는 게 맞음) ⓑ choose_one 부모 2(4-1·4-2 — 자식이 섬 · 단어는 부록 설계예시표 F0475·F0476 에서 걸림) ⓒ 단어는 설계예시표에서만 · 자기 표는 인력뿐 3(3-1 경계표시 **사유 없음** · 3-2 작업로 선정 초급기술자 못 풂 · 3-3 작업로 설치 비율 인력 못 풂) ⓓ **예취기·분무기 인력 표 한 모양 4**(6-2-2 줄베기 · 6-2-3 모두베기 · 6-4-1 덩굴걷기 **사유 없음** · 6-4-2 덩굴 약제 — 「이름 | 도구 | 인/ha | 값 | 직종」 줄을 못 읽음 · 풀리면 2장 예취기·배부식분무기 1일 몫을 체인톱처럼) ⓔ 이름에만 드론·윈치 3(3-8 드론 영상 촬영 인력만 **사유 없음** · 7-3 아키야윈치 **사유 없음** · 8-6-2 드론방제 인력 줄 못 풂) ⓕ 거리 표 2(10-13-1 드론운반 · 10-13-2 인력운반 — 형식 미정으로 건너뜀 · **사유 없음**) ⓖ 13-10-2 나무 말뚝박기 1(뭉친 칸) · 사유 없이 버려지던 것은 전체 49 로 넓혀 사유 붙음(490cab5e) · 남은 차례: 예취기 한 모양 4 → 나머지(3-2 · 3-3 · 8-6-2 · 13-10-2)
- [ ] 막힌 셋 풀기(원문에 값이 없음 · 입력·견적 칸이 서야 함): 12-26 물푸기 가동시간 · 10-7-4 모노레일 ps·임대료·운반거리 · 10-8-3 케이블크레인 ℓ/kWh · 8-6-3 지상방제 1일 작업량·1톤 트럭
- [ ] 손료계수 없는 기계 222종 되살림(미룸 · 브레인 승인 — 지금 막는 공종 0) — 가르기 끝: 원문 없음 0 · 원천 한 줄 누락 1(3201-0003 「-0003」 표기) · 뭉친 칸이지만 열마다 개수 맞고 상각+정비+관리=계 검증 통과 196(크레인(무한궤도) 15 · 골재생산(컨베이어·크러셔·스크린·빈) 55 · 해상(준설선·예선·대선 등) 66 · 그 밖 60) · 글줄로 붙음 25(3601 콘크리트 피니셔 5 · 9020 준설선 7 · 9060 토운선 6 · 7) · 원문 md 한 줄 읽기가 기존 계수 383/383 일치 · 8-4 운전경비 버려진 줄 3 = 81기종(포장 27 · 기타 36 · 기타 18) 중 공종이 쓰는 것은 물탱크 하나(되살림 끝)
- [ ] 10-7-4 모노레일 제목의 보통인부 두 번 셈(4인) — 막힌 채 사유만 적음 · 풀리는 날 운전 단가표 2인을 빼고 셀 것
- [ ] 소형 장비 1일 소모 표(㉯) 가운데 2-1-2 경유 표(집재장비) · 천공기 · 예취기 — 쓰는 공종이 풀릴 때 같은 한 벌로
### 검증완료(2026-09-14 밤 ~ 09-15 · 커밋 해시)
- [X] 사유 없이 버려지던 잎 공종 49 에 사유(브레인 차례 ① · ㉯ 자로 본 6 을 전체로 넓혀 셈) — `B09_Estimation_UnreadTables`: 일할 표(작업량·소요량·형식 미정)가 있는데 제목도 사유(못 붙은 줄·성분 빠짐·밑수·[주])도 없는 잎 공종에 「표를 읽는 길이 없어 한 줄도 안 섬 — 표 번호 모양」 을 성분 미확보로 · 읽는 길은 안 만듦(그 표가 풀리면 저절로 안 붙음) · 전후: 사유 +49 · 제목·금액 변화 0 · 프로젝트 내역에 뜨는 것 0(인계 24 공종과 안 겹침 — 화면 묶어 보이기는 아직 필요 없음) · 49 = 2-1-12 · 3장 3 · 4장 4 · 5장 6(5-26 흙갈이 셋은 형식 미정) · 6장 3 · 7장 14 · 8장 9 · 9장 4(콘크리트 깨기) · 10장 4 · 12-29 1 = 이 목록이 곧 「읽는 길」 차례표. 검증완료(490cab5e)
- [X] 조종원 직종 8-1-2 5호 원문대로(브레인 「지금 끼울 것」) — 원문이 이름·규격으로 가르는 기종만(덤프트럭 12t · 콘크리트 믹서 0.55㎥ · 공기압축기(이동식) 2.83㎥/min · 괄호 앞 이름 끝말로 · 살수차는 괄호 속) · 바뀐 레코드 정확히 12(덤프 15·20·24·32t 화물차→건설기계 · 콘크리트 믹서 0.10~0.45 · 래머 · 플레이트 콤팩터 → 일반기계) · 원문으로 가름 161 · 잠정 56(콘크리트 펌프차 · 파일천공전용장비 · 타워크레인 · 트랙터(타이어) · 크러셔 · 크롤러드릴 …) 은 옛 규칙 + 중기경비 장 사유 · 곁: 파쇄기 중기경비 장이 연료·조종원 없다고 거짓 공백을 띄우던 것 걷음 · 전후(본당): 9-14-2 14,523 → 9,177 · 10-12 L164.23m 3,249 → 3,519 · 9,172 → 9,912 · 10,520 → 11,369 · 프로젝트 본체 109,671,600 → 109,682,519(+10,919 · 덤프 운반 토사) · 거창 실무 「덤프 15Ton 건설기계 · 8·4.5Ton 화물차 · 플레이트 콤팩터 일반기계운전사」 = 원문. 검증완료(22692669)
- [X] 초류종자살포 5-24-1·2 둘이 섬(브레인 판정 ①②′④) — ① 트럭 4.5ton → 덤프트럭 4.5(범위 별칭 · 옆 칸 규격이 대상 규격과 같으면 덮는 것 아님) ②′ 종자살포기 → 취부기 11.94㎾ 손료만(`LOSS_ONLY_MACHINES` · 8-4 에 줄 없음 · 거창도 손료 20,295 만 · 디젤엔진은 원문 둘 다에 없어 안 넣음) ④ 종자 후보 넷 칸 · 넣은 하나만 · 둘이면 사유(`B09_Estimation_SeedSpray`) · 어긋남 사유 나란히 · 233 → 527(③) → 744원/㎡(재료 단가 전) · 다른 공종·프로젝트 내역 변화 0. 검증완료(3d896dec)
- [X] ③ 물탱크(살수차) 운전경비 되살림 — 8-4-8 셀 병합으로 줄째 버려지던 7204 다섯 원문 값(8.2·8.6·9.3·9.4·12.9 · 잡재료 30 · 1인) · 조종원 8-1-2 5호 살수차 = 화물차운전사 · 거창 5,500 일치. 검증완료(078af37d)
- [X] ㉡-3 ㉠ 으로 막은 다섯 표 읽기(브레인 판정 ①②③) — 판정표 「시간 줄」(`hour_rows` · F0450·F0451): 장비 h 그대로 · 인력 h ÷ 8(총칙 L530) · ÷10본 · 굴착기 0.8㎥ 무한궤도·타이어 두 갈래(제안 없음) · 14-1 은 부착용 집게 7206-0070 과 조합(본체 `#조합`) · 사유(이름 다름 · 시간 단위 · 옛 값 8배) · 막힌 셋은 옛 값 까닭 사유(12-26 보통인부 1인뿐 · 10-7-4 같은 사람 두 번 셈 · 10-8-3 특별인부 1인뿐). 옛↔새(본당): 14-2 11,356 → 무한궤도 4,431 · 타이어 4,631 · 14-1 8,603 → 3,192 · 3,316 · 12-26 172,068 · 10-7-4 688,272 · 10-8-3 226,122 은 막힌 채 · 마스터 14-1·14-2 갈래 키만 · 전체 1721 + 392. 검증완료(42e8b4f0)
- [X] 갈래마다 밑수가 다른 표 — `_match_two_row_table` 이 밑수로 안 나눠 8-6-1 유인헬기 160배·400배 부풂 → 갈래 「(Nha당)」 밑수 · 그 길 표 다섯 중 밑수 1 아닌 것 8-6-1 하나 · 전체에서 갈래마다 밑수 다른 표도 8-6-1 하나 · 소형 1,591,855 → 9,949원/ha · 대형 2,780,567 → 6,951. 검증완료(e15e9fc2)
- [X] ㉡-2 유인헬기방제 8-6-1 — `AreaSet`: 양수기 1대1일 호표(휘발유 10ℓ + 잡품 95% + 손료 칸) ÷ 1일 면적 · 깃발 20ha당 1개(칸) · 헬기 임차료(견적 칸) · 소방관서 급수 단서 · 자원 이름 「유인헬기방제 양수기·깃발」 · 소형 +227원/ha · 대형 +90 · 8-6-3 은 막힌 채 사유 둘(1일 작업량 없음 · 15ha 는 연료 설명 / 1톤 트럭 카탈로그 없음). 검증완료(9563d060)
- [X] ㉡-1 체인톱 — `B09_Estimation_Consumables`(2장 표 읽는 한 벌): 체인톱 1대1일 호표 = 휘발유 5.6 + 잡품 40% + 손료 0.0084 × 구입가(칸) · 4-2-2 「벌목부」·6-5 「특별인부 (체인톱 사용)」 인원만큼 · 체인오일 넣은 쪽 · 4-2-2 +31·+41·+53원/㎡ · 6-5 +43,821원/ha · 프로젝트 지장목제거 +548,652(본체 109,122,948 → 109,671,600). 검증완료(852ad6c7)
- [X] ㉠ 말없이 버려지던 기계·연료 줄 — 자원 축 줄 읽기 세 자리(규격 앞 장비 이름 · 값·이름 둘 다 못 읽음 · 수량 빈 연료 줄) → 「못 맞춤 + 일부만」 · 새로 사유 붙은 줄 8 · 새로 막힌 공종 5 · 처음 승인받은 `_names_a_machine` 넓히기는 재어 보니 0 을 막아 정정. 검증완료(2f9d42ac)
- [X] ㉯ 셈 — 소형 장비·연료 이름 나오는 공종 41 · 제목 선 9 모두 기계·연료 줄 0 · 금액까지 서던 7 · 4-2-2 는 부모 표에만 체인톱(제목 선 자식은 그 하나)
- [X] ㉰ 이동식 임목 파쇄 8-11 — 8-4 운전경비 「-」 라 없던 파쇄기 기계 층을 8-11 [주]⑤ 13.5ℓ·잡재료 16%·운전자 1인으로 · ㎥당 = 1/3.5 + 보통인부 2인÷8h÷Q · 파쇄기날 칸 · 51,029원/㎥. 검증완료(0fc64fa7)
- [X] ㉱ 조립 줄이 조각 「일부만」·「밑수 없음」 올림 · `_material_row` 순수 이동(710→658). 검증완료(16d34615)
- [X] ㉰ 표토제거 답외구간 9-15-2 — 불도저 식 Q1 47.92 ÷ T 0.2 = 239.60 ㎡/hr · 618원/㎡ · 영월 호표 E 자리에 e(0.96) 기록(원문 또렷하면 원문) · 프로젝트 +6,151,768(102,971,180 → 109,122,948). 검증완료(ac66a25c)
- [X] 판정표 안 읽은 줄 자동 목록(`_unread_rows`) — 9표 늘어난 줄 0·+1·+4·+8·+6·+7·+1. 검증완료(9deaf479)
- [X] 봉상후렉시블 셋 + 12-34-1 — 판정표 F0350·F0351·F0354·F0385 · 별칭 넷 → 엔진식 4611-0350 · 실무 영월 「콘크리트타설(5-5)」 같은 모양 · 새 제목 다섯. 검증완료(15d91be7)
- [X] 672 품 할증 겹침 — 합산 근거 건설 공통 1-4-2(교차 참조) · 동일성격 단서 화면. 검증완료(8dddc768)
- [X] 진동기 + 연료 종류 — (4611) 원문값 되살림 · 12-15 → 엔진식 · `fuel_kind` 대로 휘발유 · 중기경비 장 `#암석` 뒤처리 · 휘발유 기계 10종 전수 대조. 검증완료(7ea25982)
- [X] 661 암석 손료보정(8-1-7 1) — `B09_Estimation_RockLoss` · 봉화 암석 계수 셋 재현 · 바뀐 제목 17 · E1~E3 원문에 없음 · 착암기 8-3 에도 손료 줄 없음. 검증완료(a8427666)
- [X] 301 유로폼 12-38-2 — `B09_Estimation_Euroform` · 손료 표 수량 곧장 · 임대료 한 줄 · 가드 ③ 에서 12-38-02 뺌 · 봉화 재료비 4,500·3,697 일치. 검증완료(efaf2403)
- [X] 300 발파 9-5-1 — 화약류 자재 단가 칸 · 착암기 부수물 손료 고시 없음 사유. 검증완료(09247fd1)
- [X]`form_basis` 화면 — 판정표 까닭 회색 한 줄. 검증완료(c7f34b8c)
### 규칙(오늘 세운 것)
- 원문이 또렷하면 원문 · 애매하면 실무 역산 · 어긋남은 늘 기록
- 버릴 때는 사유를 남길 것(읽는 자리에서 조용히 버리지 않음)
- 빨간 시험은 구현까지 한 호흡에 · autopush 전 git status
@@ -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",
+102
View File
@@ -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 "") # 제안값 없음
+2 -1
View File
@@ -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"),
):