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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user