fix(B09): 성토·성토면다짐에 단가가 서게 함 — 금액 없이 내역에 서 있던 두 줄

물량은 인계로 오는데 단가가 없어 성토 13,150.2㎥·성토면다짐 12,350.3㎡ 가 금액 없이
서 있었음. 원문에는 값이 다 있고 막힌 까닭만 서로 달랐음.

- 노체포설(9-16-1): 기종이 [주]에만 있어 못 골랐음 → 흙깎기와 같은 장비 칸을 둠
  (무한궤도 굴착기 0.7㎥, [주]① 그대로. 화면에서 바꿀 수 있음).
- 비탈면 다짐(9-17-1): 표가 한 장도 없고 값이 절 제목에 있음(A = 77.7 ㎡/시간)
  → 제목·[주]에만 있는 작업량을 싣는 자리를 두고 원문 문구를 줄에 남김.
- 부착 콤팩터(0240-0007)는 카탈로그에 이름이 비어 있어 손료가 안 붙고 있었음 —
  이름 대신 분류번호로도 가림(조합 사용 넷은 그대로, 목록을 넓히지 않음).

금액: 성토 59,477,858원 · 성토면다짐 17,827,251원 (합 77,305,109원)이 새로 섬.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 21:59:46 +09:00
co-authored by Claude Opus 5
parent 704a10b648
commit 4a6254ba98
4 changed files with 182 additions and 5 deletions
@@ -207,6 +207,18 @@ MACHINE_CHOICES: dict[str, dict[str, Any]] = {
"암절취 깎기를 고려하여 0.7㎥ 적용한다」.",
],
},
"FP-09-16-01": {
"work_item_name": "노체포설",
"default_code": "0201-0070",
"source": "note",
"basis": [
"산림사업 표준품셈 9-16-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
"흙깎기(9-3-2)와 같이 표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.",
"⚠ 이 칸이 비어 있던 동안 **노체포설 단가가 아예 안 서고 있었습니다** — "
"계수표(K·f·E·㎝)는 있는데 기종을 못 골라 시공능력 공식이 안 돌았습니다 "
"(2026-09-09 밤에 드러남).",
],
},
"FP-09-18": {
"work_item_name": "층따기",
"default_code": "0201-0070",
@@ -255,10 +255,44 @@ def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None:
return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None
#: ⚠ **표가 아니라 절 제목·[주]에 작업량이 적힌 자리.**
#: 9-17-1 은 표가 **아예 없고** 제목 줄이 값을 든다 — 「9-17-1. 비탈면 다짐 : **A = 77.7 ㎡/시간**」.
#: 공종 마스터는 표만 절에 붙이므로 이런 자리는 **표가 0장인 공종**으로 남아 단가가 안 섰다
#: (2026-09-09 밤 실측: 성토면다짐 12,350.3㎡ 가 금액 없이 내역에 서 있었다).
#: ⚠ **값을 지어내지 않는다** — 아래 수·기종은 전부 원문 문구 그대로이고 그 문구를 함께 싣는다.
#: ⓘ 마스터가 제목의 작업량까지 싣게 되는 날 이 표는 지운다(그때는 표 쪽이 정본).
SOURCE_TEXT_CAPACITY: dict[str, tuple[dict[str, Any], ...]] = {
"FP-09-17-01": (
{
"machine_code": "0201-0070",
"machine_name": "굴착기(무한궤도)",
"capacity_per_hour": Decimal("77.7"),
"cell": "비탈면 다짐 A(㎡/hr)",
"source_text": (
"산림사업 표준품셈 9-17-1 「비탈면 다짐 : A = 77.7 ㎡/시간」 · "
"[주]② 「굴착기 부착용 유압식 진동 콤펙터와 굴착기(0.7㎥)를 적용한다」"
),
},
{
"machine_code": "0240-0007",
"machine_name": "유압식 진동콤팩터(굴착기 부착용, 0.7)",
"capacity_per_hour": Decimal("77.7"),
"cell": "비탈면 다짐 A(㎡/hr) — 부착 콤팩터",
"source_text": (
"산림사업 표준품셈 9-17-1 [주]② — 굴착기와 **함께** 도는 부착 장비라 "
"같은 작업량으로 붙는다(품셈 제8장 [주]⑤ 조합 사용)."
),
},
),
}
def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]:
"""그 공종에서 **시간당 작업량을 직접 준 기계 줄**들.
돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구.
⚠ 표에서 읽은 것에 더해 **절 제목·[주]에만 적힌 자리**(`SOURCE_TEXT_CAPACITY`)도 함께 낸다.
"""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
@@ -299,4 +333,22 @@ def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]:
"row_cells": [c for c in cells if c],
}
)
# 절 제목·[주]에만 있는 자리 — 표에서 이미 잡혔으면 **덧붙이지 않는다**(두 번 세지 않게).
already = {row["machine_code"] for row in found}
for entry in SOURCE_TEXT_CAPACITY.get(str(node.get("work_item_code") or ""), ()):
if entry["machine_code"] in already:
continue
found.append(
{
"cell": entry["cell"],
"machine_code": entry["machine_code"],
"machine_name": entry["machine_name"],
"capacity_per_hour": entry["capacity_per_hour"],
"ratio_pct": None,
"table_id": "",
"row_cells": [],
"source_text": entry["source_text"],
}
)
return found
+25 -5
View File
@@ -276,8 +276,14 @@ def _add_machine_layers(
# ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면
# 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다).
# ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다.
attachment = machine is not None and any(
word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS
# ⚠ 이름으로만 가리면 **이름이 빈 줄을 놓친다** — 카탈로그의 `0240-0007`
# (유압식 진동콤팩터, 굴착기 부착용)이 원천에서 **이름을 잃은 채** 들어와 있어
# 비탈면 다짐의 콤팩터 손료가 통째로 안 붙고 있었다(2026-09-09 밤 실측).
# 그래서 **분류번호**로도 가린다 — 아래 번호는 이미 조합 사용으로 못 박아 둔
# 그 넷(`_ATTACHMENT_PREFIXES`)이고, **목록을 넓히는 것이 아니다.**
attachment = machine is not None and (
any(word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS)
or f"X-{code}".startswith(_ATTACHMENT_PREFIXES)
)
if machine is None or machine.loss_coefficient_per_hour is None:
incomplete.append(code)
@@ -675,6 +681,12 @@ def build_unit_prices(
for label in labels:
by_item.setdefault((code, label), [])
continue
# ⚠ **표가 한 장도 없어도 작업량을 원문이 준 공종은 세운다** — 9-17-1 비탈면 다짐이
# 그 자리다(제목 줄이 「A = 77.7 ㎡/시간」을 든다). 표만 보면 영영 안 서고,
# 실제로 성토면다짐 12,350.3㎡ 가 금액 없이 내역에 서 있었다(2026-09-09 밤).
if capacity_rows.get(code):
by_item[(code, "")] = []
continue
for table in node.get("tables", []):
if isinstance(
extract_cycle_factors(code, table, factor_choices, machine_picks), CycleFactors
@@ -698,10 +710,12 @@ def build_unit_prices(
for row in rows
]
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
if not attachable and not _has_full_formula(
master, work_item_code, factor_choices, machine_picks
if (
not attachable
and not capacity_rows.get(work_item_code)
and not _has_full_formula(master, work_item_code, factor_choices, machine_picks)
):
# 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지).
# 붙을 상세도 없고 공식도 작업량도 없으면 **제목도 안 세운다**(0 원 일위대가 금지).
build.skipped.append(work_item_code)
continue
@@ -805,6 +819,12 @@ def build_unit_prices(
note=(
f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}"
f" (품셈 원문 표기 그대로)"
if not capacity.get("source_text")
# 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다.
else (
f"작업량을 원문이 직접 줌 — {capacity['cell']}"
f" {capacity['capacity_per_hour']} · {capacity['source_text']}"
)
),
)
)
@@ -0,0 +1,93 @@
"""성토·성토면다짐에 단가가 서는가 (2026-09-09 밤).
**왜 안 서고 있었나** — 둘 다 원문에는 값이 다 있는데 서로 다른 까닭으로 막혀 있었다.
9-16-1 노체포설 계수표(K·f·E·㎝)는 붙었는데 **기종이 [주]에만 있어** 못 골랐음
→ 시공능력 공식이 안 돌아 단가가 통째로 안 섬
9-17-1 비탈면 다짐 **표가 한 장도 없음** — 값이 절 제목에 있음(「A = 77.7 ㎡/시간」)
→ 표만 보는 조립이 그 공종을 아예 안 봄
⚠ 그동안 인계에는 물량이 그대로 실려 **성토 13,150.2㎥ · 성토면다짐 12,350.3㎡ 가 금액 없이**
내역에 서 있었다. 그래서 아래 셋을 못으로 박는다.
① 두 공종에 단가가 선다 · ② 단위가 원문과 같다(㎥·㎡) · ③ 근거가 줄에 남는다
④ 부착 콤팩터 손료가 함께 붙는다 — 이름이 빈 카탈로그 줄이라 놓치던 자리
"""
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.B09_Estimation_FactorChoices import MACHINE_CHOICES # noqa: E402
from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( # noqa: E402
SOURCE_TEXT_CAPACITY,
direct_capacity_rows,
)
from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402
cached_build,
load_work_item_master,
)
def _build():
return cached_build()
def test_노체포설에_단가가_선다() -> None:
build = _build()
title = build.book.titles.get("B-FP-09-16-01")
assert title is not None, "노체포설 일위대가가 안 섰습니다"
assert title.unit == ""
assert build.book.resolve("B-FP-09-16-01").total > 0
def test_노체포설_기종은_주에서_왔음을_적는다() -> None:
"""⚠ 기본값을 어디서 가져왔는지 안 적으면 임의 값과 구별이 안 된다."""
entry = MACHINE_CHOICES["FP-09-16-01"]
assert entry["default_code"] == "0201-0070" # 무한궤도 굴착기 0.7㎥
assert entry["source"] == "note"
assert any("9-16-1" in line for line in entry["basis"])
def test_비탈면다짐에_단가가_선다() -> None:
build = _build()
title = build.book.titles.get("B-FP-09-17-01")
assert title is not None, "비탈면 다짐 일위대가가 안 섰습니다"
assert title.unit == ""
assert build.book.resolve("B-FP-09-17-01").total > 0
def test_비탈면다짐은_원문_작업량을_그대로_쓴다() -> None:
"""② 77.7 은 원문 값이다 — 지어낸 수가 아님을 줄에 남긴다."""
build = _build()
rows = build.book.details["B-FP-09-17-01"]
assert rows, "상세 줄이 없습니다"
for row in rows:
# 시간 = 1 ÷ 77.7 ㎡/시간
assert row.quantity == Decimal(1) / Decimal("77.7")
assert "9-17-1" in row.note and "77.7" in row.note
def test_부착_콤팩터_손료가_함께_붙는다() -> None:
"""④ 카탈로그에 **이름이 빈 줄**(0240-0007)이라 이름으로만 가리면 놓친다."""
build = _build()
refs = {row.ref_code for row in build.book.details["B-FP-09-17-01"]}
assert "X-0240-0007" in refs, refs
# 본체 굴착기는 조합 사용 층(잡재료 16%)으로 바뀌어야 한다 — 품셈 제8장 [주]⑤.
assert any(ref.startswith("X-0201-0070#") for ref in refs), refs
assert "0240-0007" not in build.incomplete_machines
def test_제목에만_있는_작업량은_표에서_온_것과_안_겹친다() -> None:
"""⚠ 같은 기종이 표에도 있으면 두 번 세면 안 된다."""
master = load_work_item_master()
node = next(w for w in master["work_items"] if w["work_item_code"] == "FP-09-17-01")
rows = direct_capacity_rows(node)
codes = [row["machine_code"] for row in rows]
assert len(codes) == len(set(codes))
assert set(codes) == {entry["machine_code"] for entry in SOURCE_TEXT_CAPACITY["FP-09-17-01"]}