다크모드
부과내역서 집계 계약 — 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) | 배분기준(예: 사용량·전용) | — |
unitPrice | totalWeight ? principal / totalWeight : 0 — 표시용 파생(항목 부과총액 ÷ 전체 세대 가중치 합) | ❌ 검증축 아님(불변식③ 참조 — 단가×수량≈금액은 파생 구조상 항등식이라 불변식으로 채택하지 않는다. FB-16 동어반복 교훈) |
quantity | Σ line.weight(해당 세대의 배분 라인 가중치 합. 기간 중 입주·퇴거로 라인이 분할되면 각 라인의 weight에 일수비율이 이미 반영돼 있음) | 불변식③의 독립 소스 대조 대상(§3) |
amount | b.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)의 가드가 그대로 전파된다:
unitPrice는totalWeight ? 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인 항목은 행 자체가 생략된다
statementFor가 if (!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)와 동일.