feat(B09): 제잡비를 경비로 붙임 + ㉥ 물빼기 파이프 가드 + 성분 빠짐 차단 확대

제잡비 (품셈 13-6-1·13-7-1 [주]③, 원문은 메인이 찾아 줌)
- **노무비 합계 × 비율이 경비(J)로** 붙음. 재료·노무가 아님
- 기본은 **아랫단**(물빼기 파이프 미설치). 「상한」이라 산출근거에 그 사실을 적음
- `PriceDetail.percent_of_labor` 신설 — 밑수가 3분할 전체인 `percent_of_parent` 와
  다름. 비율 줄은 참조를 풀기 전에 처리(자기 자신을 가리켜 순환으로 잡혔음)
- 표시 합계에도 넣음 — 안 넣으면 화면 합계와 실제 단가가 어긋남

㉥ 가드 신설 — 제잡비 윗단(파이프 설치)을 쓰면서 물구멍을 자재로 또 실으면 멈춤
  (원문이 「상단에는 파이프 설치의 노무비·재료비를 포함한다」고 명시).
  짝 시험 4건 — 아랫단·파이프 없음·비슷한 이름은 안 걸림

성분 빠짐 차단 확대
- 시간·대수로 세는 줄(장비 몫)을 못 맞추면 그 공종 금액을 안 만듦.
  초본류 시비가 「트럭(2.5t) 2.6시간」을 빼고 33.1원/㎡ 로 서 있었음
- 자재 줄(kg·매)은 이미 알려진 미결이라 막지 않고 드러내기만 함
- 요약의 「의심 저가」에서 막아 둔 공종은 제외 — 새로 살펴야 할 것과 섞이지 않게

뭉친 표 두 곳 수정
- 칸 전체를 한 이름으로 먼저 시도 — 「굴착기 (무한궤도)」가 안 풀려 찰쌓기 13-6-2 의
  장비 몫이 빠지고 공종이 막혀 있었음
- ⚠ 라벨 줄 판정에 **순수 숫자 칸** 검사 추가 — 「자재 | 종자 | kg | 0.025」를
  라벨 줄로 오해해 씨앗뿜어붙이기 표를 가로챘음(회귀, 시험이 잡음)

결과: 자원 축 354 → 418, 일위대가 176 → 203, 막힌 공종 32
  찰쌓기 13-6-2 = 118,794.9원/㎡ (노무 + 굴착기 0.48h + 제잡비 3 %)

검증: pytest 190 통과(신규 6)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 04:22:56 +09:00
co-authored by Claude Opus 5
parent 37cb478b64
commit c2a8f7cc41
7 changed files with 1331 additions and 969 deletions
+40
View File
@@ -205,3 +205,43 @@ def check_excluded_rows_not_priced(
f"{Decimal(str(value)):,.0f} 이 붙었습니다 — 그 줄은 수량만 보이고 "
"금액을 매기지 않습니다 (PLAN 8-7 ㉡ 와 같은 성격)."
)
#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다.
#: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를
#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**.
OVERHEAD_TIER_WITH_PIPE = "with_pipe"
OVERHEAD_TIER_WITHOUT_PIPE = "without_pipe"
#: 물빼기 파이프를 가리키는 자재 이름들. **정확 일치**로만 본다 — 넓게 잡으면
#: 「물구멍 마감재」 같은 정상 자재까지 지운다.
DRAIN_PIPE_NAMES = ("물빼기파이프", "물빼기 파이프", "물구멍", "배수공")
def check_drain_pipe_not_double_counted(
*,
overhead_tier: str,
material_names: list[str],
label: str = "돌쌓기",
) -> None:
"""㉥ 제잡비 윗단을 쓰면서 물빼기 파이프를 또 세지 않았는가.
품셈 13-6-2 [주]③ 이 **윗단 값에 파이프 설치의 노무비·재료비가 포함**된다고
적어 두었다. 그 값을 쓰면서 파이프를 자재로 또 실으면 같은 것을 두 번 센다.
⚠ 둘 중 하나만 골라야 한다 — 아랫단(파이프 미설치) 값을 쓰고 파이프를 따로 세거나,
윗단 값을 쓰고 파이프를 안 세거나.
"""
if overhead_tier != OVERHEAD_TIER_WITH_PIPE:
return
tight = {"".join(str(name).split()) for name in material_names}
hit = next(
(name for name in DRAIN_PIPE_NAMES if "".join(name.split()) in tight),
None,
)
if hit is not None:
raise DoubleCountError(
f"{label}: 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 「{hit}」을 자재로 또 "
"실었습니다 — 윗단 값에 파이프의 노무비·재료비가 이미 들어 있습니다 "
"(품셈 13-6-2 [주]③)."
)
@@ -145,6 +145,10 @@ class PriceDetail:
note: str = ""
#: 비율 행(공구손료 등) — 참조 단가의 %로 계산하는 줄.
percent_of_parent: Decimal | None = None
#: **노무비 합계**의 %로 붙는 경비 줄 — 제잡비(품셈 13-6-1 [주]③).
#: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고,
#: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다.
percent_of_labor: Decimal | None = None
@dataclass
@@ -191,6 +195,13 @@ class PriceBook:
total = Money3()
for row in rows:
# ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로
# 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다).
if row.percent_of_labor is not None:
# 제잡비 — **노무비 합계**의 %가 **경비**로 붙는다(품셈 13-6-1 [주]③).
total = total + Money3(expense=total.labor * row.percent_of_labor / Decimal(100))
continue
child = self.resolve(row.ref_code, (*_seen, code))
if row.percent_of_parent is not None:
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
@@ -455,6 +455,9 @@ class AxisResult:
rows: list[ResourceRow] = field(default_factory=list)
unmatched: list[UnmatchedRow] = field(default_factory=list)
skipped_forms: dict[str, int] = field(default_factory=dict)
#: 제잡비 비율(%) — `{공종코드: (윗단, 아랫단)}`. 윗단은 물빼기 파이프 설치,
#: 아랫단은 미설치 (품셈 13-6-2 [주]③). 값이 하나뿐이면 둘이 같다.
overhead_ratio: dict = field(default_factory=dict)
#: 자원은 알아봤는데 **값을 못 읽은** 줄이 있는 공종 — 그 단가는 「일부만 선 것」이다.
#: 기초잡석 12-25 가 `소할(30%) | 할석공(인) | 0.2 × 30%` 를 못 읽어 부설다짐만으로
#: 107,145 원이 서고 있었다(2026-09-08). **부분 성공이 가장 위험하다.**
@@ -481,6 +484,19 @@ def _tidy_resource_name(cell: str) -> str:
return tight + sep + tail
#: 장비 줄의 단위 — 시간·대수로 센다. 자재는 kg·매·㎥ 로 센다.
_MACHINE_UNITS = ("시간", "hr", "h", "", "시 간")
def _is_machine_like_row(cells: list[str]) -> bool:
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
for cell in cells:
text = _normalize(cell)
if text and any(text == unit or text.replace(" ", "") == unit for unit in _MACHINE_UNITS):
return True
return False
def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
"""셀 하나를 카탈로그 한 줄로 푼다 — 세 가지 모양을 차례로 시도한다.
@@ -579,6 +595,20 @@ def match_table(
name_cell = cells[1]
value_cells = cells[2:]
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
if "제잡비" in _normalize(name_cell):
ratios = [
parse_amount(_normalize(token))
for cell in value_cells
for token in _RE_ALTERNATIVE.sub(r"", _normalize(cell)).split(" ")
]
ratios = [x for x in ratios if x is not None]
if ratios:
upper = ratios[0]
lower = ratios[1] if len(ratios) > 1 else ratios[0]
result.overhead_ratio[node["work_item_code"]] = (upper, lower)
continue
# ⚠ **공식 계수를 단 줄은 자원 줄이 아니다.** 「유압식백호우 … | k | 0.9」 처럼
# 같은 줄에 버킷계수가 붙어 오는데, 그 0.9 를 소요량으로 읽으면 **시간당 사용료가
# 0.9시간분** 붙어 이중이 된다(2026-09-08: 이름 표기를 맞추자 측구터파기에
@@ -650,6 +680,14 @@ def match_table(
result.unmatched.append(
UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason)
)
# ⚠ **시간·대수로 세는 줄은 장비 몫**이다 — 못 맞추면 그 공종 단가가
# 노무만으로 서서 조용히 싸진다(2026-09-08: 초본류 시비가 「트럭(2.5t)
# 2.6시간」을 빼고 33.1원/㎡ 로 섰다). 자재 줄(kg·매)은 이미 알려진
# 미결이라 막지 않고 드러내기만 한다.
if _is_machine_like_row(value_cells):
result.partial_items[node["work_item_code"]] = (
f"{_normalize(name_cell)[:20]} (장비 줄)을 못 맞췄습니다"
)
continue
try:
@@ -319,6 +319,11 @@ def match_packed_rows(
# 자원으로 **안 풀리는** 줄이면 갈래 라벨 줄로 본다.
if not labels:
return False
# ⚠ **라벨 줄에는 순수 숫자 칸이 없다.** 「35cm 이하」는 수를 품되 순수 수가 아니고,
# 「자재 | 종 자 | | kg | 0.025」는 순수 수(0.025)가 있는 **자료 줄**이다.
# 이 구분을 안 두면 씨앗뿜어붙이기 표를 라벨 줄로 오해해 표째 가로챈다(2026-09-08 회귀).
if any(parse_amount(cell) is not None for cell in rows[0]):
return False
# ⚠ **첫 줄의 어느 칸이라도 자원이면 라벨 줄이 아니다.** 첫 칸만 보면 기초잡석
# 12-25 처럼 「소할(30%) | 할석공(인) | 0.2 × 30%」인 표를 라벨 줄로 오해해
# 표째 가로챈다(2026-09-08: 그 탓에 기초잡석이 다시 막혔다).
@@ -352,10 +357,29 @@ def match_packed_rows(
for index, cells in enumerate(rows[1:], start=1):
if not cells or not cells[0]:
continue
names = _packed_names(cells[0])
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
# 갈래마다 같은 값이 되풀이되므로 **첫 값 칸**만 본다. 「9(9) 3(3)」 = 윗단 9 · 아랫단 3.
if "제잡비" in _normalize_label(cells[0]).replace(" ", ""):
for cell in cells[1:]:
numbers = _packed_numbers(cell)
if numbers:
upper = numbers[0]
lower = numbers[1] if len(numbers) > 1 else numbers[0]
result.overhead_ratio[work_item_code] = (upper, lower)
break
continue
# 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」.
side_specs = [cell for cell in cells[1:3] if cell]
resolved = [_resolve_packed(catalog, name, side_specs) for name in names]
# ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에
# 공백이 있으면 쪼개서 보다가 통째로 못 푼다(2026-09-08: 찰쌓기 13-6-2 의
# 장비 몫이 그래서 빠지고 공종이 막혔다).
whole = _resolve_packed(catalog, _normalize_label(cells[0]), side_specs)
if whole:
names, resolved = [_normalize_label(cells[0])], [whole]
else:
names = _packed_names(cells[0])
resolved = [_resolve_packed(catalog, name, side_specs) for name in names]
if not all(resolved):
if _packed_numbers(" ".join(cells[1:])):
# 수가 있는데 이름을 못 풀었다 — 그 몫이 빠진 채 서면 안 된다.
@@ -295,6 +295,23 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
share = _share_of(row)
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
# 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③).
# ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면
# 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다.
# ⚠ **「상한」이다** — 곱한 값 이하로 계상하는 값이라 산출근거에 그 사실을 적는다.
ratio = axis.overhead_ratio.get(work_item_code)
if ratio is not None and not variant_key.startswith("__"):
lower = ratio[1]
build.book.add_detail(
PriceDetail(
title_code,
title_code,
_ZERO,
note=f"제잡비 노무비의 {lower}% (상한, 물빼기 파이프 미설치 기준)",
percent_of_labor=lower,
)
)
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
machine_share = (
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
@@ -504,10 +521,13 @@ def build_summary(build: UnitPriceBuild) -> dict:
"median": _money_text(totals[len(totals) // 2]),
"max": _money_text(totals[-1]),
}
# ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라
# 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다.
low = [
{"code": code, "name": title.name, "total": _money_text(money)}
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE
and code[2:].split("#")[0] not in build.partial_ratio
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
]
@@ -525,6 +545,8 @@ def build_summary(build: UnitPriceBuild) -> dict:
"unit_price_totals": stats,
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
"suspiciously_low": low,
# 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다.
"blocked_items": len(build.partial_ratio),
# 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
"unknown_basis_high": high,
"unknown_basis": sum(
@@ -579,6 +601,29 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
money = build.book.resolve(code)
rows: list[dict] = []
for detail in build.book.details.get(code, []):
if detail.percent_of_labor is not None:
# 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야
# 화면 합계와 실제 단가가 어긋나지 않는다.
labor_so_far = sum((Decimal(str(r["labor"])) for r in rows), _ZERO)
amount = labor_so_far * detail.percent_of_labor / Decimal(100)
rows.append(
{
"code": detail.ref_code,
"name": "제잡비",
"spec": f"노무비의 {detail.percent_of_labor}%",
"unit": "%",
"quantity": str(detail.percent_of_labor),
"material": "0",
"labor": "0",
"expense": str(amount),
"total": _money_text(amount),
"source": "품셈 [주]",
"drillable": False,
"note": detail.note,
}
)
continue
child = build.book.title(detail.ref_code)
unit_money = build.book.resolve(detail.ref_code)
line = unit_money.scaled(detail.quantity)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff