feat(B09): 열이 자원인 표(전치형) 읽기 + 오독 3종 차단
품셈에는 자원이 행이 아니라 **열 머리**에 오는 표가 39건 있음 (「구 분 | 콘크리트공(인) | 보통인부(인)」, 행은 무근·철근·소형구조물). 행-자원으로 읽어 콘크리트 타설(12-1)이 하나도 안 서고 있었음 - 갈래(variant)마다 따로 세움 — 품이 달라 뭉치면 어느 것도 안 맞음. 코드는 `B-FP-12-01-01#무근구조물`, 이름에 갈래를 적어 사람이 고를 수 있음 - 레디믹스트콘크리트 타설: 무근 58,635.0 / 철근 65,826.4 / 소형 117,270.0 원 읽다가 잡은 오독 3종 — 전부 **고쳐 읽지 않고 버림** (자리를 단정할 수 없음) - 병합 셀로 한 칸 밀린 행: 목재틀흙막이가 등급 글자를 건축목공 자리에 두어 단가 503만원으로 섰음 - 좌우 두 판이 붙은 표: 소운반에서 거리값 60 이 「보통인부 60인」으로 읽혀 1,553만원이 섰음. 열 머리가 되풀이되면 표째 버림 - 같은 갈래 이름 중복: 목재틀흙막이 「상」이 8.760 + 13.767 로 합산돼 667만원이 섰음. 그 줄들을 버림 - 갈래 이름에 자원용 머리글 필터를 쓰던 것도 고침 — 「중」·「상」 등급이 통째로 지워지고 있었음. 합계 줄만 걸러 냄 기준 단위 미상 드러내기 (막지 않음 — 막으면 125 중 122 가 멈춤) - `unknown_basis` 122건, 그중 100만원 넘는 10건을 목록으로 냄 (떼채취 평떼 1,032,408원 등 — 「100매당」류 묶음 기준 의심) - 내역서 줄 비고에도 「기준 단위가 표에 없습니다 — 수량 단위와 같다고 보고 곱했습니다」를 적음. 반영률 문구를 덮지 않고 이어 붙임 700줄 제한으로 전치형 처리를 `_Transposed.py` 로 분리 (647 + 138줄) 검증: pytest 163 통과(신규 5). 자원 축 198줄·갈래 54, 일위대가 85 → 125, 분포 최소 233.4 · 중앙 72,268.5 · 최대 5,030,954.6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,11 @@ _ZERO = Decimal(0)
|
||||
FUEL_CODE_PREFIX = "M-FUEL-"
|
||||
#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다.
|
||||
SUSPICIOUSLY_LOW_KRW = Decimal(100)
|
||||
#: 기준 단위를 모르는 채 이 금액을 넘으면 **사람이 한 번 봐야 한다**.
|
||||
#: 품셈 표가 「10㎡당」처럼 묶음 기준일 수 있어 값 자체는 맞고 기준만 모르는 경우가 많다
|
||||
#: (2026-09-08: 목재틀흙막이 상등구조 = 건축목공 16.975인 → 503만원. 값은 품셈대로다).
|
||||
#: **막지 않고 드러내기만 한다** — 막으면 120 중 117 이 멈춘다.
|
||||
SUSPICIOUSLY_HIGH_KRW = Decimal(1_000_000)
|
||||
|
||||
|
||||
def _slots(value: Decimal) -> list[Decimal | None]:
|
||||
@@ -76,6 +81,8 @@ class UnitPriceBuild:
|
||||
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
|
||||
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
|
||||
cycle_factors: dict[str, CycleFactors] = field(default_factory=dict)
|
||||
#: 공종 하나가 낳은 규격 갈래들 — 「무근구조물」·「철근구조물」·「소형구조물」.
|
||||
variants: dict[str, list[str]] = field(default_factory=dict)
|
||||
#: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다.
|
||||
factor_gaps: dict[str, FactorGap] = field(default_factory=dict)
|
||||
|
||||
@@ -208,12 +215,14 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
|
||||
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
|
||||
|
||||
by_item: dict[str, list] = {}
|
||||
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
|
||||
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
|
||||
by_item: dict[tuple[str, str], list] = {}
|
||||
for row in axis.rows:
|
||||
by_item.setdefault(row.work_item_code, []).append(row)
|
||||
by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row)
|
||||
|
||||
for work_item_code, rows in sorted(by_item.items()):
|
||||
title_code = f"B-{work_item_code}"
|
||||
for (work_item_code, variant), rows in sorted(by_item.items()):
|
||||
title_code = f"B-{work_item_code}" + (f"#{variant}" if variant else "")
|
||||
if title_code in build.book.titles:
|
||||
continue
|
||||
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
|
||||
@@ -229,22 +238,27 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
continue
|
||||
|
||||
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
|
||||
base_name = names.get(work_item_code) or work_item_code
|
||||
build.book.add_title(
|
||||
PriceTitle(
|
||||
code=title_code,
|
||||
kind=PriceKind.UNIT_PRICE,
|
||||
name=names.get(work_item_code) or work_item_code,
|
||||
spec=work_item_code,
|
||||
name=f"{base_name} ({variant})" if variant else base_name,
|
||||
spec=variant or work_item_code,
|
||||
unit=unit,
|
||||
)
|
||||
)
|
||||
if variant:
|
||||
build.variants.setdefault(work_item_code, []).append(variant)
|
||||
# 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다.
|
||||
for row, ref in attachable:
|
||||
share = _share_of(row)
|
||||
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
|
||||
|
||||
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
|
||||
machine_share = _attach_machine_share(build, master, work_item_code, title_code)
|
||||
machine_share = (
|
||||
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
|
||||
)
|
||||
|
||||
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
|
||||
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
|
||||
@@ -453,11 +467,27 @@ def build_summary(build: UnitPriceBuild) -> dict:
|
||||
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
|
||||
]
|
||||
|
||||
# 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다.
|
||||
high = [
|
||||
{"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 not title.unit
|
||||
and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW
|
||||
]
|
||||
|
||||
return {
|
||||
"titles": len(build.book.titles),
|
||||
"unit_price_totals": stats,
|
||||
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
|
||||
"suspiciously_low": low,
|
||||
# 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
|
||||
"unknown_basis_high": high,
|
||||
"unknown_basis": sum(
|
||||
1
|
||||
for code, title in build.book.titles.items()
|
||||
if title.kind is PriceKind.UNIT_PRICE and not title.unit
|
||||
),
|
||||
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
|
||||
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
|
||||
"skipped_work_items": len(build.skipped),
|
||||
|
||||
Reference in New Issue
Block a user