Skip to content

부과내역서 집계 계약 — BE 참고

관리비 상용화 필수 문서 트랙(펀치리스트 점검 ④)의 2호(② 부과내역서) — ①총괄표(report-summary-table.md) 패턴을 그대로 복제. 설계 정본: docs/superpowers/specs/2026-07-10-charge-statement-collection-status-design.md. as-built 정본 소스: src/composables/useChargeStatement.js.


1. 개요

부과내역서는 선택한 세대 하나의 당기(이번 차수) 부과 내역을 항목별 산출근거(배분기준·단가·수량·금액)로 펼쳐 보여주는 read-only 문서다. 총괄표가 "결과 수치"의 세대×항목 매트릭스라면, 부과내역서는 그 셀 하나(세대×항목)가 어떤 배분 라인 조합으로 만들어졌는지를 드릴다운한다. 재계산은 일절 하지 않는다useAllocations.allocationBreakdown이 이미 산출한 라인별 배분 결과를 그대로 조합·표시할 뿐이다.

2. 데이터 정본 — useChargeStatement

src/composables/useChargeStatement(){ unitOptions, statementFor, formatAmount }.

내부적으로 3개 기존 컴포저블만 소비한다:

js
const { allocationBreakdown, invoicingByUnit, formatAmount } = useAllocations();
const { activeCharges } = useCharges();
const { invoiceFor } = useInvoice();

2-1. 세대 선택 목록 = 총괄표와 동일한 행집합

js
const unitOptions = () =>
  invoicingByUnit().map((r) => ({
    unitCode: r.unit.unitCode,
    name: r.unit.name ?? r.unit.unitCode,
  }));

useAllocations().invoicingByUnit()(당기 배분을 보유한 유닛만)을 그대로 재사용한다 — 총괄표의 행집합(docs/handoff/backend/report-summary-table.md §2-1)과 완전히 동일한 정본. prior-only 세대(당기 신규 부과 없이 전기이월 미수만 있는 세대)는 선택 목록에 나타나지 않는다(수납현황과의 차이 — §4 참고).

2-2. statementFor(unitCode) — 항목별 산출근거 조합

js
function statementFor(unitCode) {
  const rows = [];
  for (const charge of activeCharges.value) {
    const b = allocationBreakdown(charge, "unit", unitCode);
    if (!b.lines.length) continue;
    rows.push({
      chargeCode: charge.chargeCode,
      name: b.chargeName,
      basis: b.chargeBasis,
      unitPrice: b.totalWeight ? b.principal / b.totalWeight : 0,
      quantity: b.lines.reduce((s, l) => s + l.weight, 0),
      amount: b.cellTotal,
      lines: b.lines,
    });
  }
  const s = invoiceFor({ axis: "unit", key: unitCode })?.summary ?? {};
  return {
    rows,
    세전합계: s.세전부과원금 ?? 0,
    부가세: s.부가세 ?? 0,
    당월합계: (s.세전부과원금 ?? 0) + (s.부가세 ?? 0),
  };
}

원천은 useAllocations().allocationBreakdown(charge, 'unit', unitCode)(src/composables/useAllocations.js:125-152) — 반환 shape:

{ chargeName, chargeBasis, principal, totalWeight, lines:[{label, subLabel, basisQuantity, occupancyDays, monthDays, dayRatio, weight, share, amount, isResidual}], cellTotal }

행이 없는 항목(그 세대에 배분 라인이 0개인 항목)은 if (!b.lines.length) continue로 스킵한다 — 항목 열이 세대마다 다르게 나타날 수 있다(총괄표는 모든 활성 항목을 항상 열로 고정하지만, 부과내역서는 실제 라인이 있는 항목 행만 노출).

2-3. 필드 계약

필드정의검증축 여부
chargeCode / name부과항목 식별
basis (chargeBasis)배분기준(예: 사용량·전용)
unitPricetotalWeight ? principal / totalWeight : 0표시용 파생(항목 부과총액 ÷ 전체 세대 가중치 합)❌ 검증축 아님(불변식③ 참조 — 단가×수량≈금액은 파생 구조상 항등식이라 불변식으로 채택하지 않는다. FB-16 동어반복 교훈)
quantityΣ line.weight(해당 세대의 배분 라인 가중치 합. 기간 중 입주·퇴거로 라인이 분할되면 각 라인의 weight에 일수비율이 이미 반영돼 있음)불변식③의 독립 소스 대조 대상(§3)
amountb.cellTotal(= 그 세대·그 항목의 배분 라인 합계 — 재계산 없이 allocationBreakdown이 이미 계산한 값을 복사)불변식① 대상
lines원본 배분 라인 배열 그대로(점유자·계약·가중치·잔차 여부 등 드릴다운 원천)

2-4. 요약 = InvoiceVM.summary 재사용 (총괄표 2-4와 동형)

js
const s = invoiceFor({ axis: "unit", key: unitCode })?.summary ?? {};

세전합계·부가세·당월합계는 고지서 VM의 summary에서 그대로 가져온다 — 총괄표·고지서와 동일한 계산 경로이므로 세 화면 간 숫자 drift가 구조적으로 불가능하다.

3. 불변식 4종 (vitest, src/composables/__tests__/useChargeStatement.spec.js)

#불변식지키는 회계 정합
각 행 amount === byUnit(charge) 피벗 해당 셀(.find(g => g.unit.unitCode === unitCode)?.total)부과내역서 행 금액이 항목×세대 피벗(총괄표 셀 소스)과 항등 — 별도 계산 경로가 아님
Σ행.amount === invoiceFor(unit).summary.세전부과원금 === statement.세전합계부과내역서 합계가 고지서 세전 부과원금과 항등 — 감사·민원 대응 문서와 청구서가 서로 다른 숫자를 낼 수 없음
수량 독립 대조: basis === '사용량'이면 line.basisQuantity === usageOf(chargeCode, unitCode)(useMeterReadings) · basis === '전용'이면 line.basisQuantity === unit.exclusiveArea수량이 파생 재계산이 아니라 독립 소스(검침·유닛마스터)에서 왔음을 검증 — 단가×수량=금액 자기순환 검증(동어반복)을 피하는 설계(FB-16 교훈)
전세대 Σ statementFor(unit).rows[chargeCode].amount === useChargeSummaryTable().columnTotals(unitRows()).cells[chargeCode]부과내역서를 전세대 합산하면 총괄표 열 합계와 항등 — 세 보고서(총괄표·부과내역서·고지서)가 같은 원장의 세 투영일 뿐임을 구조적으로 고정

as-built 진단(2026-07-10): 4개 불변식 모두 최초 구현에서 통과 — allocationBreakdown을 그대로 복사·재조합만 하는 설계라 새 계산 경로가 없기 때문(총괄표 §3와 동일 근거).

4. 엣지케이스

4-1. totalWeight === 0 (검침 미입력 등, 항목의 가중치 전량 0)

useAllocations.allocateDetailed(src/composables/useAllocations.js:75-98)의 가드가 그대로 전파된다:

  • unitPricetotalWeight ? principal/totalWeight : 0이므로 totalWeight===0이면 0으로 표시(0으로 나누기 방지, NaN 아님).
  • 배분 라인 자체가 amount: totalWeight ? ... : 0으로 전부 0이 되므로(잔차 라인도 흡수하지 않음 — "전액집중 소멸 버그" FB-3 재현 방지), cellTotal도 0. 이 경우 원금(principal)은 unallocated로 표면화되며(청구확정 게이트가 별도로 잡음), 부과내역서 화면에는 해당 항목이 amount:0으로 뜬다(무음 유실 아님 — 청구 게이트 쪽에서 별도 경고).
  • 이 상황은 이번 차수 검침 입력이 누락된 사용량 기준 항목에서 발생할 수 있다. BE가 검침 데이터 완결성을 보장하지 못하면 부과내역서에 0원 행이 뜰 수 있음을 인지할 것.

4-2. lines.length === 0인 항목은 행 자체가 생략된다

statementForif (!b.lines.length) continue로 필터하므로, 그 세대에 배분 라인이 하나도 없는 항목(예: 신규 입주로 해당 항목 배분 대상이 아닌 경우)은 부과내역서에 행으로 나타나지 않는다 — "0원 행"과 "행 자체 없음"은 다른 상태다. 총괄표는 활성 항목을 항상 열로 고정하므로(빈 셀은 0 표시), 두 문서의 "항목 존재 여부" 표현 방식이 다르다는 점에 유의.

4-3. 세대 선택 목록(unitOptions) = prior-only 세대 미포함

총괄표와 동일하게 invoicingByUnit()을 행집합으로 쓰므로, prior-only 세대(당기 신규 부과 없음)는 부과내역서 세대 드롭다운에도 나타나지 않는다. 수납현황(docs/handoff/backend/report-collection-status.md)은 반대로 이 세대들을 포함한다 — 두 문서의 행집합 정본이 의도적으로 다르다(설계 spec §1 "문서 역할 분담" 참고).

5. 확장 포인트

  • 대량(njk) 인쇄 파이프: 부과내역서·고지서 묶음 모두 세대 수 비례 문서 — 실구현은 스코프 밖(문서류 매트릭스 REVIEW-INVOICE-MASS-PRINT-2026-07-10.md §6 명기만). 대량 필요 시 statementFor가 세대별로 독립 호출 가능한 순수 함수이므로 njk 배치 렌더에 그대로 재사용 가능(재계산 로직 없음 — VM 계약만 JSON 직렬화하면 됨).
  • 고지서 묶음: 신규 서식이 아니라 기존 InvoiceDynamicOrg를 세대 순회 재사용(FE handoff §3 참고) — BE 계약은 고지서(invoice-print-pipeline.md)와 동일.