fix(B08): 인계 계약 정정 — 반영률 세 칸 · 할증 상태 세 갈래

- 반영률: `quantity`(적용 후) · `quantity_gross`(적용 전) · `application_ratio_pct`
  세 칸을 함께 실음. 곱하기는 B08 한 곳에서만 하고 B09 는 산출근거에만 적음.
  100 % 인 줄도 율을 적음 — 칸이 비면 받는 쪽이 적용 여부를 단정 못 함.
  `verify_ratio_math()` 가 세 값의 정합을 재고 `build_handoff()` 가 실제로 부름.
- 할증 상태를 세 갈래로: `applied` / `not_applied` / `rate_unavailable`.
  두 갈래면 「율을 못 찾아 안 붙인 것」이 「붙였다」로 나가 나중에 진짜 율이
  들어왔을 때 한 번 더 붙음. 옛 깃발은 실제로 붙었을 때만 참으로 둠.
- 관급구분 `unknown` 이 세 번째 계약값임을 주석에 명시. B09 는 관급자재대에도
  도급 재료비에도 넣지 않고 missing 으로 뺌.
- 운반 줄이 실제로 실리는지 시험으로 못 박음(무대 in_bill=false 포함 4줄).

⚠ 반영률 누락 지적은 사실이 아니었음 — 실서버 확인 결과 네 반영률이 전부 100 %
이고, 성토면다짐·층따기가 같은 사면 면적에서 나와 값이 겹친 것임. 곱셈은
정상 동작 중. 다만 세 칸 계약은 이중 적용을 막으므로 그대로 반영.

검증 — 인계 40건 통과, 전체 523 passed. 실서버에서 세 칸 정합·할증 상태
`rate_unavailable`·검산 경고 0 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 00:26:27 +09:00
co-authored by Claude Opus 5
parent c7d917864c
commit 96c304ac35
3 changed files with 91 additions and 10 deletions
@@ -44,7 +44,13 @@ class SummaryRow:
item: str = "" # 공종 (토사·연암·…)
spec: str = "" # 규격 (기계(굴삭기)·백호우·…)
unit: str = ""
amount: float = 0.0
amount: float = 0.0 # 반영률을 **곱한 뒤** 값 — 내역서에 쓰는 값
# ⚠ 반영률 **적용 전** 값과 쓴 율을 함께 남긴다 (2026-09-07 3자 계약).
# 곱하기는 **B08 한 곳에서만** 한다. B09 가 율만 보고 또 곱하면 값이 두 배가 된다.
# 반영률 개념이 없는 줄은 `None` 이고, 100 % 인 줄도 **100.0 을 적는다** —
# 칸이 비어 있으면 「적용됐는지」를 받는 쪽이 단정할 수 없다.
amount_gross: float | None = None
application_ratio_pct: float | None = None
note: str = ""
# 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡).
in_bill: bool = True
@@ -83,9 +89,7 @@ def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float,
if given <= 0:
return [("", total, "")]
note = "" if abs(given - 100.0) < 1e-9 else f"입력 합 {given:g} % → 100 % 로 안분"
return [
(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0
]
return [(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0]
def build_rows(source: SummaryInput) -> list[SummaryRow]:
@@ -106,9 +110,7 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
for name, amount, note in _split_by_rock(earth.get(rock_key, 0.0), source):
rows.append(
SummaryRow(
group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note
)
SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note)
)
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
@@ -125,18 +127,29 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
group="성토면다짐",
unit="",
amount=fill_face * _ratio(source, "fill_slope_compaction"),
amount_gross=fill_face,
application_ratio_pct=_ratio(source, "fill_slope_compaction") * 100.0,
note=_ratio_note(source, "fill_slope_compaction", "성토면"),
)
)
seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio(
source, "seed_spray_cut"
)
# ⚠ 성·절토면 율이 다를 수 있어 **한 줄에 하나의 율**로 못 적는다. 적용 전 합을 함께 두고
# 율은 두 율이 같을 때만 적는다 — 다르면 `None` 이고 비고에 두 율이 적힌다.
seed_gross = fill_face + cut_face
seed_fill_ratio = _ratio(source, "seed_spray_fill")
seed_cut_ratio = _ratio(source, "seed_spray_cut")
rows.append(
SummaryRow(
group="초류종자살포",
spec="씨드스프레이",
unit="",
amount=seed,
amount_gross=seed_gross,
application_ratio_pct=(
seed_fill_ratio * 100.0 if seed_fill_ratio == seed_cut_ratio else None
),
note=_seed_note(source),
)
)
@@ -146,6 +159,8 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
group="지장목제거",
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
)
)
@@ -215,6 +230,8 @@ def build_table(source: SummaryInput) -> dict[str, Any]:
"spec": row.spec,
"unit": row.unit,
"amount": row.amount,
"amount_gross": row.amount_gross,
"application_ratio_pct": row.application_ratio_pct,
"note": row.note,
"in_bill": row.in_bill,
}
+41 -1
View File
@@ -18,6 +18,13 @@
④예산내역서에 무대가 서서 운반비가 두 번 붙는다. 빼고 넘기지 않는 까닭은,
빠진 줄과 제외된 줄을 나중에 구별할 수 없기 때문이다.
⚠ **반영률은 B08 한 곳에서만 곱한다** (2026-09-07 3자 계약)
`quantity` 는 **곱한 뒤** 값이고 `quantity_gross` 는 곱하기 전 값이며 `application_ratio_pct`
는 쓴 율이다. **셋을 함께 싣는 까닭**은 받는 쪽이 「이미 곱해졌나」를 단정할 수 있어야
하기 때문이다 — 율만 보내면 B09 가 또 곱해 값이 두 배가 된다. 100 % 인 줄도 `100.0` 을
적고, `None` 은 **반영률 개념이 없는 줄**에만 쓴다.
`verify_ratio_math()` 가 세 값이 서로 맞는지 실제로 재 본다.
⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택)
값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을
세울 수 있다(울진 2 · 거창 5 · 오솔길 1). 설정 파일을 안 봐도 **인계본만으로 ④가 서게** 한다.
@@ -175,6 +182,9 @@ def _earthwork_rows(
"spec": str(row.get("spec") or ""),
"unit": str(row.get("unit") or ""),
"quantity": float(row.get("amount") or 0.0),
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
"quantity_gross": row.get("amount_gross"),
"application_ratio_pct": row.get("application_ratio_pct"),
"ground_class": ground,
"haul_distance_m": None,
"haul_equipment": None,
@@ -213,6 +223,9 @@ def _haul_rows(
"spec": str(row.get("ground") or ""),
"unit": "",
"quantity": float(row.get("volume_m3") or 0.0),
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
"quantity_gross": None,
"application_ratio_pct": None,
"ground_class": row.get("ground") or None,
"haul_distance_m": float(row.get("average_distance_m") or 0.0),
"haul_equipment": equipment,
@@ -251,6 +264,8 @@ def _structure_rows(
"spec": _spec_detail(structure),
"unit": "m",
"quantity": length,
"quantity_gross": None,
"application_ratio_pct": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
@@ -317,7 +332,7 @@ def build_handoff(
unmatched.extend(misses)
materials = _material_rows(material_table or {})
return {
result: dict[str, Any] = {
"work_items": work_items,
"materials": materials,
# 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다.
@@ -336,6 +351,9 @@ def build_handoff(
}
),
# 자재 쪽에만 할증이 있다 — 작업 공종에는 없다.
# ⚠ 세 갈래로 그대로 나른다(`applied`·`not_applied`·`rate_unavailable`).
# 「율이 없어 못 붙인 것」을 「붙였다」로 말하면 B09 가 나중에 한 번 더 붙인다.
"surcharge_status": (material_table or {}).get("surcharge_status"),
"surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")),
"unmatched_work_items": sorted(set(unmatched)),
"mapping_pending_user": table.pending_user,
@@ -349,6 +367,9 @@ def build_handoff(
"bill_row_count": sum(1 for row in work_items if row["in_bill"]),
"excluded_row_count": sum(1 for row in work_items if not row["in_bill"]),
}
# ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다.
result["ratio_math_warnings"] = verify_ratio_math(result)
return result
def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]:
@@ -364,6 +385,25 @@ def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]:
return found
def verify_ratio_math(handoff: dict[str, Any], *, tolerance: float = 1e-6) -> list[str]:
"""⚠ `quantity == quantity_gross × 율/100` 이 실제로 맞는지 재 본다.
세 칸을 실어 두고 **서로 어긋나면** 받는 쪽이 어느 값을 믿을지 알 수 없다.
「만들어 두고 안 부르면 없는 것과 같다」를 피하려고 `build_handoff()` 가 직접 부른다.
"""
found: list[str] = []
for row in handoff.get("work_items") or []:
gross = row.get("quantity_gross")
ratio = row.get("application_ratio_pct")
if gross is None or ratio is None:
continue
expected = float(gross) * float(ratio) / 100.0
actual = float(row.get("quantity") or 0.0)
if abs(expected - actual) > max(tolerance, abs(expected) * 1e-9):
found.append(f"{row.get('name')}: {actual:g}{gross:g} × {ratio:g} %")
return found
def verify_bill_flags(handoff: dict[str, Any]) -> list[str]:
"""⚠ 코드가 없는데 내역에 서는 줄이 있으면 알린다.
@@ -23,6 +23,10 @@
「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면
(`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다.
⚠ 관급구분은 **세 값**이다 — `owner_supplied` · `contractor_supplied` · `unknown`
`unknown` 은 「아직 안 정함」이고 **지어내지 않겠다는 뜻**이다. B09 는 이 줄을 관급자재대에도
도급 재료비에도 넣지 않고 `missing` 으로 뺀다(2026-09-07 계약에 명시).
⚠ 관급/사급은 **법이 아니라 발주 결정**이다
자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정
(`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨
@@ -71,6 +75,14 @@ INSTALL_BY_OWNER = "owner" # 관 직접설치
INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"}
NOTE_INSTALL_BY_MISSING = "설치 주체 미지정"
#: ⚠ 할증 상태는 **세 갈래**다 (2026-09-07 3자 계약 정정).
#: 두 갈래(`True`/`False`)로 두면 「율을 못 찾아 안 붙인 것」이 「붙였다」로 나가고,
#: 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다. **깃발과 실제가 어긋나지 않을 것**이
#: 요건이므로 상태를 그대로 말한다.
SURCHARGE_APPLIED = "applied" # 한 줄이라도 실제로 붙음
SURCHARGE_NOT_APPLIED = "not_applied" # 붙일 줄이 없음(자재 자체가 없음)
SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 못 찾음
NOTE_RATE_MISSING = "할증률 미확보"
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
@@ -161,6 +173,15 @@ class MaterialRow:
return " · ".join(parts)
def _surcharge_status(rows: list[MaterialRow]) -> str:
"""할증이 실제로 붙었는가 — 세 갈래로 답한다."""
if not rows:
return SURCHARGE_NOT_APPLIED
if any(row.surcharge_pct is not None and not row.surcharge_included for row in rows):
return SURCHARGE_APPLIED
return SURCHARGE_RATE_UNAVAILABLE
def _supply_of(value: Any) -> tuple[str, str | None]:
"""설정 한 칸을 (관급구분, 설치주체) 로 읽는다.
@@ -293,8 +314,11 @@ def build_table(
}
for row in ordered
],
# 이 표가 할증을 붙인 곳임을 못 박는다 — B09 는 다시 붙이지 않는다(㉠).
"surcharge_applied": True,
# ⚠ **깃발이 실제와 어긋나지 않게** 한다. 「붙일 자리였는데 율이 없어 못 붙였다」를
# 「붙였다」로 말하면, 나중에 율이 들어왔을 때 B09 가 한 번 더 붙인다.
"surcharge_status": _surcharge_status(ordered),
# 옛 두 갈래 깃발 — **실제로 붙었을 때만** 참이다(호환을 위해 남긴다).
"surcharge_applied": _surcharge_status(ordered) == SURCHARGE_APPLIED,
"surcharge_dataset": {
"effective_date": table.effective_date,
"source": table.source,