diff --git a/B09_Estimation/B09_Estimation_BasisSheet.py b/B09_Estimation/B09_Estimation_BasisSheet.py index 006bfb3a..ee948bf4 100644 --- a/B09_Estimation/B09_Estimation_BasisSheet.py +++ b/B09_Estimation/B09_Estimation_BasisSheet.py @@ -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: diff --git a/B09_Estimation/B09_Estimation_Brushcutting.py b/B09_Estimation/B09_Estimation_Brushcutting.py new file mode 100644 index 00000000..8194eca3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Brushcutting.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Consumables.py b/B09_Estimation/B09_Estimation_Consumables.py index 01a90a12..51429be2 100644 --- a/B09_Estimation/B09_Estimation_Consumables.py +++ b/B09_Estimation/B09_Estimation_Consumables.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index 550c4258..e091ff58 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -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)」 줄을 " diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py index 8d1226f4..04c0da1a 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py @@ -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 줄" diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index e97a7adb..7aa278a3 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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"))) diff --git a/B09_Estimation/B09_Estimation_Router_Factors.py b/B09_Estimation/B09_Estimation_Router_Factors.py index f2bbc9c6..026a41f3 100644 --- a/B09_Estimation/B09_Estimation_Router_Factors.py +++ b/B09_Estimation/B09_Estimation_Router_Factors.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_UI_Factors.ts b/B09_Estimation/B09_Estimation_UI_Factors.ts index fba13b20..e83d409f 100644 --- a/B09_Estimation/B09_Estimation_UI_Factors.ts +++ b/B09_Estimation/B09_Estimation_UI_Factors.ts @@ -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; + seedlings_per_ha?: string; }, ): Promise { 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) — 고른 것만 붙습니다")); diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 496693b3..5005265c 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -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), ) diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index 2df1d6be..6c5990b4 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -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", diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 45c5cf4a..2bef77a0 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -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", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index d8ef761d..f2fe5443 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_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", diff --git a/resources/tester/test_b09_brushcutter.py b/resources/tester/test_b09_brushcutter.py new file mode 100644 index 00000000..5e65e110 --- /dev/null +++ b/resources/tester/test_b09_brushcutter.py @@ -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 "") # 제안값 없음 diff --git a/resources/tester/test_b09_unread_tables.py b/resources/tester/test_b09_unread_tables.py index f4f246bc..4469a8e7 100644 --- a/resources/tester/test_b09_unread_tables.py +++ b/resources/tester/test_b09_unread_tables.py @@ -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"), ):