fix(axis): 표 읽기 1차 — 뭉친 줄 표기 맞추기·비고 거름·「-」 칸 · 지장목제거를 품셈에 이음

- 뭉친 줄 해석기: 「굴 삭 기 (무한궤도)」를 굴착기(무한궤도)로 맞춰 큰돌쌓기 메쌓기(13-6-1)·
  13-7-2 장비 몫이 붙음(형식을 적은 이름만 · 형식 없는 굴삭기는 판정 Ⓒ대로 규격 미정)
- 비고·합계 줄을 자원으로 안 읽음(단목베기 4-2-2 가짜 막힘 제거) · 「-」 칸은 그 갈래에 품 없음
  (산림복원용 흙막이 13-13-2 가 특별인부·굴삭기 빠진 채 조용히 서던 것을 사유와 함께 막음)
- 지장목제거(판정 Ⓑ): 잡관목제거 → FP-04-02-02 #5m미만 · 뿌리뽑기 → FP-09-21 + 임목축적 등급 갈래 ·
  준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만(이중계상 막이) · 미결 목록에서 뺌
- 기계 이름 별칭(백호·굴삭기→굴착기)은 별칭표 대상이 아닌 카탈로그 이름 정규화로 주석 정리
- 일위대가 전후 대조(HEAD↔수정): 새로 선 공종 13-6-1·13-7-2 · 새로 막힌 공종 13-13-2(사유 있음) ·
  내역 금액: 단목베기·제근은 밑수 미확보로 계속 막혀 변화 없음 · 전체 시험 1513 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-13 17:52:04 +09:00
co-authored by Claude Opus 5
parent 5a619e8a20
commit 4eceedc7d9
13 changed files with 218 additions and 58 deletions
@@ -176,17 +176,21 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
# 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
# ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀.
# 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.**
# ⭐ 2026-09-13 브레인 판정 — 지장목제거는 **품셈에 있다**(벌목 4장 + 제근 9-20~21 단계 합산).
# 잡관목제거 = 단목베기 1,000㎡당 「5m 미만」(4-2-2) · 뿌리뽑기 = 제근(9-21, 판정 Ⓑ).
# 준비공 「제근·뿌리다듬기」는 같은 면적이라 **참조로만** 보인다(이중계상 막이).
for item, why in (
(
"뿌리뽑기",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름"
"(같은 면적을 나눠 씀 · 이중계상 아님). 품셈 9-21 제근으로 이음 —"
" 준비공 「제근·뿌리다듬기」는 참조로만 보임(2026-09-13 판정)",
),
(
"잡관목제거",
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
" ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상",
" 품셈 4-2-2 단목베기(1,000㎡당) 「5m 미만」으로 이음"
"(2026-09-13 판정 · 실무 영월 1.9.2)",
),
):
rows.append(
+6 -1
View File
@@ -111,6 +111,7 @@ def build_handoff(
concrete_placing_method: str | None = None,
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
stand_volume_class: str | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
table = mapping or load_mapping()
@@ -119,7 +120,11 @@ def build_handoff(
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
rows, misses = _earthwork_rows(summary_table, table, methods, bench_cut_depth_m)
# 임목축적 등급 — 지장목제거 뿌리뽑기(9-21 제근)의 품 갈래(2026-09-13 판정 Ⓑ).
variant_inputs = {"stand_volume_class": stand_volume_class}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
)
work_items.extend(rows)
unmatched.extend(misses)
if haul_table:
@@ -222,17 +222,30 @@ class WorkItemMapping:
found[str(code)] = str(unit)
return found
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
def for_earthwork(
self, group: str, ground: str | None, item: str | None = None
) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.
`item` 은 **작업 갈래**(지장목제거의 「잡관목제거」) — 매핑 줄에 `item` 이 있으면 같아야 함.
"""
exact = [
row
for row in self.earthwork
if row.get("group") == group and row.get("ground") == ground
if row.get("group") == group
and row.get("ground") == ground
and ("item" not in row or row.get("item") == item)
]
if exact:
return exact[0]
# 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다.
loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row]
loose = [
row
for row in self.earthwork
if row.get("group") == group
and "ground" not in row
and ("item" not in row or row.get("item") == item)
]
return loose[0] if loose else None
def for_haul(self, equipment: str) -> dict[str, Any] | None:
@@ -93,9 +93,12 @@ def _earthwork_rows(
mapping: WorkItemMapping,
methods: dict[str, str | None],
bench_cut_depth_m: float | None = None,
variant_inputs: dict[str, str | None] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""토공집계표 줄을 내역 줄로 옮긴다.
`variant_inputs` — 매핑 줄이 `variant_from` 으로 가리키는 **설정값**(임목축적 등급 등).
⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다.
빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다.
"""
@@ -115,7 +118,9 @@ def _earthwork_rows(
# ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다.
is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS
lookup_ground, method_note = _mapping_ground(ground, methods)
entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None
entry = (
mapping.for_earthwork(group, lookup_ground, work_kind) if method_note == "" else None
)
code = (entry or {}).get("work_item_code")
# ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18).
# 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다.
@@ -169,9 +174,12 @@ def _earthwork_rows(
"spec_detail": spec_detail,
"composite_parts": None,
"structure_kind": None,
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
"variant_axis": None,
"variant_value": None,
# 토공 줄은 대개 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
# 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다.
"variant_axis": (entry or {}).get("variant_axis"),
"variant_value": (entry or {}).get("variant_value")
or (variant_inputs or {}).get(str((entry or {}).get("variant_from") or ""))
or None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
@@ -134,9 +134,8 @@ def preparation_rows(
"대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — "
"원문이 가르지 않고 실무도 한 덩이로 셈). "
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음."
" 지금은 공종코드 없이 감."
"공종은 토공집계 줄이 가짐 — 잡관목제거는 품셈 4-2-2 단목베기(FP-04-02-02)"
" 「5m 미만」, 뿌리뽑기는 9-21 제근(FP-09-21)(2026-09-13 판정)."
),
"work_item_code": None,
},
@@ -259,6 +258,12 @@ ROOT_REMOVAL_BASIS = (
"밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어"
" ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀"
)
#: ⭐ 2026-09-13 판정 Ⓑ — 제근은 **토공 줄**이다(지식DB `토공_수량.md:30` 「뿌리다듬기·적재·제근 |
#: 9-20~21 | 벌개제근 연동」). 실무 서식(영월 1.9.1 뿌리뽑기)대로 토공집계가 세고 여기는 참조.
ROOT_COUNTED_IN_SUMMARY = (
"토공집계 「지장목제거 · 뿌리뽑기」가 품셈 9-21 제근(FP-09-21)으로 셈 — 같은 면적·같은 작업이라"
" 여기서 또 세우면 이중계상(2026-09-13 판정)"
)
ROOT_REMOVAL_CLASS_MISSING = (
"임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·"
"중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서"
@@ -273,7 +278,11 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
"""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
picked = str(stand_volume_class or "").strip()
reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"]
reasons = [
ROOT_COUNTED_IN_SUMMARY,
ROOT_REMOVAL_BASIS,
"대상 면적은 지장목제거와 같은 자리(벌개제근 연동)",
]
if picked in STAND_VOLUME_CLASSES:
reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름")
else:
@@ -285,10 +294,12 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
"item": "제근·뿌리다듬기",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING,
"reason": " · ".join(reasons),
"reference_amount": area,
"work_item_code": "FP-09-21",
"work_item_code": None,
}
@@ -295,6 +295,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
bench_cut_depth_m=settings.get("bench_cut_depth_m"),
# 용수 유무 — 기본 「육상」은 **통상값**이다(확정 3차 ④). 사유·화면에 그 사실이 뜬다.
structure_trench_water=settings.get("structure_trench_water"),
# 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임.
stand_volume_class=settings.get("stand_volume_class"),
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -50,6 +50,9 @@ _KEY_CYCLE = ("㎝(sec)", "cm(sec)", "cm", "㎝")
#: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다.
#: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다.
#: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다.
#: ⚠ **별칭표(`common_util_aliases`) 대상이 아니다** — 품셈 전 장에서 같은 기종을 달리 적는
#: **카탈로그 이름 정규화**라 범위(scope)가 없다(2026-09-13 브레인 판정). 규격·형식은 안 바꾸고
#: 이름 머리만 맞추므로, 형식이 둘인 기종은 여전히 「규격 미정」으로 남는다.
MACHINE_NAME_ALIASES = {
"유압식백호우": "굴착기",
"백호우": "굴착기",
@@ -29,7 +29,10 @@ from B09_Estimation.B09_Estimation_ResourceAxis import (
ResourceCatalog,
ResourceRow,
UnmatchedRow,
_tidy_resource_name,
is_non_resource_label,
parse_amount,
parse_machine_cell,
split_name_and_spec,
)
@@ -210,6 +213,8 @@ _RE_PACKED_NUMBER = re.compile(r"\d+(?:\.\d+)?")
#: 「1.04(1.17)」의 괄호 값 — **조건 시공 시 대안값**이라 기본 수에 안 센다
#: (품셈 13-6-1 [주]②). 안 떼면 값이 하나 더 있는 것으로 보여 표가 통째로 버려진다.
_RE_ALTERNATIVE_TAIL = re.compile(r"(?<=\d)\s*[(]\s*\d+(?:\.\d+)?\s*[)]")
#: 「그 갈래엔 없음」 표시 칸.
_ABSENT_MARKS = frozenset({"-", "", "·", "", ""})
def _packed_numbers(cell: str) -> list:
@@ -235,11 +240,26 @@ def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None
parts = [part.strip() for part in str(name).split("+") if part.strip()]
found = []
for part in parts:
# ⚠ 행 읽기 길과 **같은 표기 맞추기**를 먼저 한다 — 이 길만 「굴 삭 기 (무한궤도)」를
# 「굴착기(무한궤도)」로 못 바꿔 큰돌쌓기 메쌓기(13-6-1) 장비 몫이 막혀 있었다(09-13).
# ⚠ **기종 이름이 실제로 바뀌고 형식(괄호)을 적었을 때만** 쓴다 — 공백만 지우면
# 「부착용 집게」가 카탈로그 앞머리 비교에서 빠져 돌쌓기(장비) 넷이 막혔고(같은 날 대조로
# 잡음), 형식 없는 「굴 삭 기」까지 바꾸면 무한궤도 **잠정** 우선순위로 새 단가가 선다
# (판정 Ⓒ 「형식이 둘이면 규격 미정」과 어긋남).
tidied = _tidy_resource_name(part)
if _normalize_label(tidied) != _normalize_label(part) and "(" in tidied:
part = tidied
base, spec = split_name_and_spec(part)
entry = catalog.resolve(base, spec)
if entry is None and not spec:
candidates = catalog.by_name(base)
entry = candidates[0] if len(candidates) == 1 else None
if entry is None:
# 갈래를 이름에 품은 기종(「굴착기(무한궤도)」)은 **그 이름째** 옆 칸 규격으로 먼저 —
# 앞머리 「굴착기」로만 보면 무한궤도·타이어가 둘 다 걸려 잠정 우선순위에 기댄다.
machine_name, _ = parse_machine_cell(part)
if machine_name != base:
entry = _resolve_with_side_spec(catalog, machine_name, specs or [])
if entry is None:
entry = _resolve_with_side_spec(catalog, base, specs or [])
if entry is None:
@@ -370,6 +390,10 @@ def match_packed_rows(
break
continue
# 비고·합계 줄은 자원이 아니다 — 문장 속 수(「100m」·「30%」)를 값으로 보고 「못 풀었다」며
# 공종을 막고 있었다(단목베기 4-2-2 「비고」, 2026-09-13). 행 읽기 길과 같은 거름을 쓴다.
if is_non_resource_label(cells[0]):
continue
# 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」.
side_specs = [cell for cell in cells[1:3] if cell]
# ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에
@@ -395,7 +419,12 @@ def match_packed_rows(
)
continue
groups = [_packed_numbers(cell) for cell in cells[1:]]
# ⚠ 「-」·「·」 칸은 **그 갈래에 품이 없다**는 표시다 — 빈칸처럼 버리면 갈래 수가 모자라
# 표째 버려진다(산림복원용 흙막이 13-13-2 「특별인부 0.014 | -」). 자리만 지키고 안 셈.
groups = [
_packed_numbers(cell) or ([None] * len(names) if cell in _ABSENT_MARKS else [])
for cell in cells[1:]
]
groups = [group for group in groups if group]
# 앞쪽에 규격·단위 칸이 낄 수 있다 — 「0.6㎥ | 시간 | 0.31 | 0.30 | 0.28」.
# 갈래 수만큼 **뒤에서** 잘라 쓴다.
@@ -417,6 +446,8 @@ def match_packed_rows(
for label, group in zip(labels, groups):
for entries, amount in zip(resolved, group):
if amount is None:
continue # 「-」 — 그 갈래에는 이 자원이 안 든다
value = amount
if basis_quantity not in (None, 0, Decimal(1)):
value = value / basis_quantity
@@ -125,6 +125,28 @@
"basis_source": "산림사업 표준품셈(고시 2025-82) 12-25 기초잡석 「(단위: ㎥당)」.",
"master_name": "기초잡석",
"note": "품셈은 운반·부설·다짐 품만 주고 **두께·폭을 정하지 않는다.** 폭은 버림 폭과 같고(KCS 34 50 05) 두께는 사용자 확정 3차 ②(0.2m)다 — 화면에서 바꿀 수 있다."
},
{
"group": "지장목제거",
"item": "잡관목제거",
"work_item_code": "FP-04-02-02",
"master_name": "단목베기 > 1,000㎡당",
"basis_unit": "㎡",
"basis_source": "산림사업 표준품셈(고시 2025-82) 4-2-2 절 이름 「1,000㎡당」 — ⚠ 표 안·절 머리에 「(단위: …)」가 없어 마스터 밑수가 비어 있음(basis_missing F0087). B09 가 밑수 미확보로 막으므로 1,000배 틀린 금액은 안 섬.",
"variant_axis": "tree_height",
"variant_value": "5m 미만",
"note": "지장목제거 = 벌목(4장) + 제근(9-20~21) 단계 합산(2026-09-13 브레인 판정 · 원가_출력변수_사전.md:55). 실무 영월 1.9.2 「잡관목제거 벌목(5m미만)」이 단목베기 1,000㎡당 5m 미만 갈래와 같은 이름·같은 단위(㎡)."
},
{
"group": "지장목제거",
"item": "뿌리뽑기",
"work_item_code": "FP-09-21",
"master_name": "토공 > 제근",
"basis_unit": "㎡",
"basis_source": "⚠ 교차 참조 — 산림품셈 9-21 표에 밑수 표기가 없어 건설공사 표준품셈 3-9-2 뿌리뽑기 「1,000㎡당」 면적 축을 빌려 씀(사용자 확정 5차 6번). 마스터 밑수는 비어 있음(basis_missing F0294).",
"variant_axis": "stand_volume_class",
"variant_from": "stand_volume_class",
"note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김."
}
],
"haul": [
@@ -235,15 +257,6 @@
"pending_user": {
"note": "이름이 비슷한 후보는 있으나 **어느 것인지 정할 근거가 없는** 자리. 임의로 고르지 않고 unmatched 로 낸다(CLAUDE.md 3장).",
"items": [
{
"group": "지장목제거",
"candidates": [
"FP-04-01 수확베기",
"FP-04-02 단목베기",
"FP-04-03 위험목 베기"
],
"why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음"
},
{
"group": "흙깎기/측구터파기 암",
"candidates": [
+70 -18
View File
@@ -94,7 +94,8 @@ def test_구조물은_한_줄로_서고_전개_성분은_안_옴() -> None:
assert len(placing) == 1
# 그 줄의 물량은 **버림 콘크리트 몫뿐**이어야 한다 — 채움이 섞이면 이중계상이다.
blinding = next(
c for c in build_unit_table([구조물()])["structures"][0]["components"]
c
for c in build_unit_table([구조물()])["structures"][0]["components"]
if c["name"] == "버림콘크리트"
)
assert placing[0]["quantity"] == pytest.approx(blinding["amount"], abs=1e-6)
@@ -249,7 +250,11 @@ def test_매핑_파일이_없으면_전부_드러남() -> None:
def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None:
"""지장목제거·암 갈래는 후보만 있고 고를 근거가 없다 — 임의 확정 금지(3장)."""
"""암 갈래는 고를 근거가 없다 — 임의 확정 금지(3장).
⭐ 2026-09-13 판정으로 지장목제거는 작업 갈래(뿌리뽑기 9-21 · 잡관목제거 4-2-2)로 이어졌다 —
**작업 갈래가 없는** 지장목제거 줄만 여전히 못 잇는다.
"""
handoff = build_handoff(summary_table=집계표(집계줄("지장목제거", amount=100.0)))
assert handoff["work_items"][0]["work_item_code"] is None
assert "지장목제거" in handoff["unmatched_work_items"]
@@ -259,7 +264,7 @@ def test_정할_근거가_없는_자리는_지어내지_않고_알림() -> None:
item.get("group") or item.get("type_id")
for item in handoff["mapping_pending_user"]["items"]
]
assert "지장목제거" in keys
assert "지장목제거" not in keys # 판정으로 닫힘(2026-09-13)
# ⚠ 큰돌쌓기는 **닫혔다** — 랩탑이 `bond`(메쌓기/찰쌓기) 칸을 만들어 자동으로 갈린다.
# 미결 목록에서 빠졌는지도 함께 본다(고쳐졌는데 목록만 남는 것 방지).
assert "boulder_masonry" not in keys
@@ -954,7 +959,9 @@ def test_검사가_실제로_잡는다_일부러_깨뜨려_봄():
handoff = build_handoff(unit_quantity_table=build_unit_table([구조물()]))
handoff["materials"].append({"material_name": "가짜자재", "work_item_code": "FP-99-99"})
assert verify_no_code_on_materials(handoff) == ["가짜자재"]
handoff["work_items"].append({"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None})
handoff["work_items"].append(
{"name": "코드없이내역에선줄", "in_bill": True, "work_item_code": None}
)
assert "코드없이내역에선줄" in verify_bill_flags(handoff)
@@ -983,7 +990,12 @@ def test_타설_줄이_실제로_선다() -> None:
_콘크리트구조물(
[
{"name": "콘크리트", "unit": "", "amount": 13.5, "destination": "unit_price"},
{"name": "이형철근 D13", "unit": "kg", "amount": 134.5, "destination": "material"},
{
"name": "이형철근 D13",
"unit": "kg",
"amount": 134.5,
"destination": "material",
},
]
)
]
@@ -999,7 +1011,13 @@ def test_타설_줄이_실제로_선다() -> None:
def test_방식을_안_정하면_기본값으로_서되_알린다() -> None:
unit = {"structures": [_콘크리트구조물([{"name": "콘크리트", "unit": "", "amount": 2.0, "destination": "unit_price"}])]}
unit = {
"structures": [
_콘크리트구조물(
[{"name": "콘크리트", "unit": "", "amount": 2.0, "destination": "unit_price"}]
)
]
}
handoff = build_handoff(unit_quantity_table=unit)
타설 = next(r for r in handoff["work_items"] if r["name"] == "콘크리트 타설")
assert 타설["work_item_code"] == "FP-12-01-01" # 기본값 = 레디믹스트
@@ -1008,7 +1026,13 @@ def test_방식을_안_정하면_기본값으로_서되_알린다() -> None:
def test_방식을_바꾸면_공종이_갈린다() -> None:
unit = {"structures": [_콘크리트구조물([{"name": "콘크리트", "unit": "", "amount": 2.0, "destination": "unit_price"}])]}
unit = {
"structures": [
_콘크리트구조물(
[{"name": "콘크리트", "unit": "", "amount": 2.0, "destination": "unit_price"}]
)
]
}
코드 = {}
for method in ("ready_mixed", "machine_mixed", "hand_mixed"):
handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method=method)
@@ -1024,7 +1048,13 @@ def test_방식을_바꾸면_공종이_갈린다() -> None:
def test_콘크리트가_없으면_타설_줄도_없다() -> None:
"""0 ㎥ 짜리 빈 줄을 만들지 않는다."""
unit = {"structures": [_콘크리트구조물([{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}])]}
unit = {
"structures": [
_콘크리트구조물(
[{"name": "야면석", "unit": "ton", "amount": 3.0, "destination": "material"}]
)
]
}
handoff = build_handoff(unit_quantity_table=unit, concrete_placing_method="ready_mixed")
assert not [r for r in handoff["work_items"] if r["name"] == "콘크리트 타설"]
@@ -1053,7 +1083,9 @@ def test_개소_구조물은_연장으로_안_센다() -> None:
성분은 개소 기준으로 맞게 서는데 **줄의 축만** 어긋나 있어 아무 시험도 안 잡았다.
"""
handoff = build_handoff(unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"}))
handoff = build_handoff(
unit_quantity_table=build_unit_table([_집수정(2.0)], {"pipe_inlet_basin": "집수정"})
)
= next(r for r in handoff["work_items"] if r["name"] == "집수정")
assert (["unit"], ["quantity"]) == ("개소", 1.0)
@@ -1063,7 +1095,9 @@ def test_개소_구조물은_연장이_길어도_한_개소() -> None:
긴것 = build_unit_table([_집수정(5.0)], {"pipe_inlet_basin": "집수정"})
for table in (한개, 긴것):
= next(
r for r in build_handoff(unit_quantity_table=table)["work_items"] if r["name"] == "집수정"
r
for r in build_handoff(unit_quantity_table=table)["work_items"]
if r["name"] == "집수정"
)
assert (["unit"], ["quantity"]) == ("개소", 1.0)
@@ -1080,7 +1114,9 @@ def test_m당_구조물은_그대로_연장으로_센다() -> None:
}
= next(
r
for r in build_handoff(unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"}))["work_items"]
for r in build_handoff(
unit_quantity_table=build_unit_table([옹벽], {"retaining_wall": "옹벽"})
)["work_items"]
if r["name"] == "옹벽"
)
assert (["unit"], ["quantity"]) == ("m", 20.0)
@@ -1120,7 +1156,10 @@ def test_못_내는_줄도_사유와_함께_간다() -> None:
# ⭐ 2026-09-09 확정 5차 6번으로 **밑수가 면적 축으로 정해져 값이 선다** — 그래서 이 줄은
# 더 이상 「못 내는 줄」의 표본이 아니다. 「못 내는 줄도 사유와 함께 간다」는 계약 자체는
# 아래 표토·부대시설 줄이 지킨다.
assert 제근["in_bill"] is True and 제근["quantity"] > 0
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 뿌리뽑기(FP-09-21)가 하고 이 줄은 **보이되 안 실린다**.
assert 제근["in_bill"] is False and 제근["quantity"] > 0
assert 제근["work_item_code"] is None and 제근["blocked_kind"] is None
assert "토공집계" in 제근["in_bill_reason"]
assert 제근["unit"] == "" # 밑수가 면적 축으로 확정됨(확정 5차 6번)
# ⚠ 사유가 **두 번 바뀐 자리**다 — ㉠ 「입목 본수가 없다」(틀린 말: 본수 축이 아니었다)
# ㉡ 「밑수 단위가 원문에 없다」(사실이었으나 확정으로 닫힘) ㉢ 지금은 **값이 서고**
@@ -1173,7 +1212,14 @@ def test_묶음이_없는_종류는_그대로_타설_줄이_선다() -> None:
unit_quantity_table={
"structures": [
_콘크리트구조물(
[{"name": "콘크리트", "unit": "", "amount": 2.84, "destination": "unit_price"}]
[
{
"name": "콘크리트",
"unit": "",
"amount": 2.84,
"destination": "unit_price",
}
]
)
]
},
@@ -1203,9 +1249,7 @@ def test_연장으로_서는_공종은_전개식이_없어도_안_막힌다() ->
앞서 「성분이 없으면 전개식 없음」으로 단정해 B09 가 「우리가 만들 것」으로 빼
**금액이 0** 이었다(실측: 맹암거 40m 이 0원 → 고친 뒤 1,019,685원).
"""
handoff = build_handoff(
unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]}
)
handoff = build_handoff(unit_quantity_table={"structures": [_B군("underdrain", "맹암거")]})
= next(r for r in handoff["work_items"] if r["name"] == "맹암거")
assert ["work_item_code"] == "FP-12-10"
assert ["blocked_kind"] is None, ["blocked_reason"]
@@ -1283,8 +1327,16 @@ def test_겹치지_않으면_원합을_안_싣는다() -> None:
def test_공종을_못_이은_B군은_사유와_함께_막힌다() -> None:
= [{"type_id": "chute", "group": "B", "name": "도수로·산비탈수로",
"count": 1, "length_m": 40.0, "raw_length_m": 40.0}]
= [
{
"type_id": "chute",
"group": "B",
"name": "도수로·산비탈수로",
"count": 1,
"length_m": 40.0,
"raw_length_m": 40.0,
}
]
= build_handoff(length_table=)["work_items"][0]
assert ["work_item_code"] is None
assert ["in_bill"] is False
+3 -3
View File
@@ -110,10 +110,10 @@ def test_셈이_맞을것() -> None:
# 사용자가 무엇을 정할지 안다(원단위 미확보에서 「표에 있는 규격을 함께 알린」 그 방식).
def test_지장목제거_줄에_공종_미확정_사유가_적힐것() -> None:
def test_지장목제거_줄에_공종_자리가_적힐것() -> None:
"""⭐ 2026-09-13 판정 — 잡관목제거는 단목베기(4-2-2), 뿌리뽑기는 제근(9-21)."""
row = (build_table(), "벌목·지장목제거")
assert "공종 미확정" in row["reason"]
assert "수확베기" in row["reason"] # 후보가 무엇인지도 함께 보인다
assert "FP-04-02-02" in row["reason"] and "FP-09-21" in row["reason"]
# ── 규준틀 개소 — 원문이 기준을 정해 둠 (2026-09-07 ㉒) ────────────
@@ -44,7 +44,11 @@ def 집계() -> dict:
def 줄들() -> list[dict]:
return [row for row in build_handoff(summary_table=집계())["work_items"] if row["name"] == "지장목제거"]
return [
row
for row in build_handoff(summary_table=집계())["work_items"]
if row["name"] == "지장목제거"
]
def test_두_줄이_선다() -> None:
@@ -59,7 +63,9 @@ def test_같은_면적이_두_줄에_들어간다() -> None:
def test_이중계상이_아님이_사유에_적힌다() -> None:
for row in 줄들():
assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str(row["spec_detail"])
assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str(
row["spec_detail"]
)
def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None:
@@ -67,11 +73,21 @@ def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None:
for row in 줄들():
assert row["ground_class"] is None
unmatched = build_handoff(summary_table=집계())["unmatched_work_items"]
assert "지장목제거" in unmatched
assert not any("지장목제거(" in item for item in unmatched)
assert not any("지장목제거" in item for item in unmatched) # 두 작업 갈래 모두 이어짐
def test_잡관목제거는_품셈에_이름이_없음이_남는() -> None:
def test_잡관목제거는_단목베기_5m_미만으로_이어진() -> None:
"""⭐ 2026-09-13 판정 — 지장목제거는 품셈에 있다(벌목 4장 + 제근 9-20~21)."""
잡관목 = next(row for row in 줄들() if row["spec"] == "잡관목제거")
사유 = str(잡관목["spec_detail"]) + str(잡관목["in_bill_reason"])
assert "품셈에 그 이름이 없어" in 사유
assert 잡관목["work_item_code"] == "FP-04-02-02"
assert 잡관목["variant_value"] == "5m 미만"
뿌리뽑기 = next(row for row in 줄들() if row["spec"] == "뿌리뽑기")
assert 뿌리뽑기["work_item_code"] == "FP-09-21"
assert 뿌리뽑기["variant_axis"] == "stand_volume_class"
def test_뿌리뽑기는_임목축적_등급을_갈래로_싣는다() -> None:
"""⭐ 판정 Ⓑ — 품이 등급(소림·중림·밀림)으로 갈려 갈래를 잃으면 안 된다."""
rows = build_handoff(summary_table=집계(), stand_volume_class="중림")["work_items"]
뿌리뽑기 = next(r for r in rows if r["name"] == "지장목제거" and r["spec"] == "뿌리뽑기")
assert 뿌리뽑기["variant_value"] == "중림"
@@ -54,4 +54,6 @@ def test_금액이_서는_줄은_수량째_본체에_선다() -> None:
in_bill = [
r["name"] for r in handoff["work_items"] if r["origin"] == "preparation" and r["in_bill"]
]
assert {"표토제거", "제근·뿌리다듬기", "뿌리 적재"} <= set(in_bill)
assert {"표토제거", "뿌리 적재"} <= set(in_bill)
# ⭐ 2026-09-13 판정 Ⓑ — 제근은 토공집계 뿌리뽑기가 셈. 준비공 줄은 참조(제외 목록)로 간다.
assert "제근·뿌리다듬기" not in in_bill