다크모드
관리비 총괄표 집계 계약 — BE 참고
관리비 상용화 필수 문서 트랙(펀치리스트 점검 ④)의 1호 — 총괄표만 1차 스코프, 부과내역서·수납현황은 후속. 설계 정본:
docs/superpowers/specs/2026-07-10-charge-summary-table-design.md. as-built 정본 소스:src/composables/useChargeSummaryTable.js.
1. 개요
관리비 총괄표는 당기(이번 차수) 부과 내역을 세대×항목 매트릭스로 재구조화해 보여주는 read-only 보고서다. 재계산은 일절 하지 않는다 — 기존 캐논(useAllocations·useCharges·useInvoice)이 이미 산출한 숫자를 피벗·합산해서 보여줄 뿐이다. 이 설계가 곧 정합성 보증이다: 총괄표와 고지서가 서로 다른 계산 경로를 타지 않으므로 구조적으로 같은 숫자가 나온다(불변식 §3).
2. 데이터 정본 — useChargeSummaryTable
src/composables/useChargeSummaryTable() → { chargeColumns, unitRows, columnTotals, totalExclusiveArea, kapt, formatAmount }.
내부적으로 3개 기존 컴포저블만 소비한다:
js
const { byUnit, invoicingByUnit, formatAmount } = useAllocations();
const { activeCharges } = useCharges();
const { invoiceFor } = useInvoice();2-1. 행 집합 = 당기 부과 세대
행 소스는 useAllocations().invoicingByUnit() — 당기 배분을 보유한 유닛만 포함한다. prior-only 세대(당기 신규 부과 없이 전기이월 미수만 있는 세대)는 1차 제외된다. 이는 오너 승인 스코프 결정(설계 §4 "스코프 아웃")이며, 화면에 "당기 부과 세대 기준" 문구로 정직하게 명기된다. 후속 확장 시 이 컴포저블에 별도 prior-only 행 병합 로직을 추가해야 한다(§5 확장 포인트).
2-2. 열 집합 = 활성 항목
useCharges().activeCharges(설정 > 항목에서 활성 상태인 항목만) 순서 그대로 열로 노출한다. useAllocations의 배분 캐논 자체가 이미 activeCharges 기반이므로, 총괄표가 별도로 활성 필터를 재구현하지 않아도 비활성 항목이 섞일 여지가 원천 차단된다.
2-3. 셀 = byUnit(charge) 피벗 (세전)
js
function cellMatrix() {
const matrix = {};
for (const charge of activeCharges.value) {
for (const g of byUnit(charge)) {
(matrix[g.unit.unitCode] ??= {})[charge.chargeCode] = g.total;
}
}
return matrix;
}useAllocations().byUnit(charge)는 항목별 유닛 그룹 배열을 반환한다(FB-31 수정 이후 각 유닛의 첫 실소비처 라인만 채택하는 피벗 — 이중 계상 가드). 각 그룹의 total이 곧 그 유닛의 그 항목 공급가(세전) 합계이며, 총괄표 셀 값은 이 total을 그대로 복사한다 — 재계산 없음.
2-4. 우측 요약 열 = InvoiceVM.summary 재사용
js
const s = invoiceFor({ axis: "unit", key: unitCode })?.summary ?? {};useInvoice().invoiceFor()가 산출하는 고지서 VM의 summary에서 세전부과원금·부가세·미납원금·미납연체금·선수금·청구액을 그대로 가져와 행에 붙인다(당월합계 = 세전부과원금 + 부가세, 총괄표 층에서 파생하는 유일한 표시용 합산). 이 항목들은 고지서 화면과 동일한 계산 경로를 거치므로 두 화면 간 숫자 drift가 구조적으로 불가능하다.
2-5. K-apt 뷰 — ㎡당 단가
js
function totalExclusiveArea() {
return invoicingByUnit().reduce((s, r) => s + (r.unit.exclusiveArea ?? 0), 0);
}전용면적 소스 = 유닛 마스터 units.exclusiveArea(demoSeed 기존 필드). 배분 라인의 basis.area(항목별 배분 산식 기초값)를 쓰지 않는다 — 유닛 마스터가 더 안정적인 정본이고, "여러 배분 라인 중 어느 라인의 area를 쓸지" 선택 문제 자체가 발생하지 않는다(2026-07-10 T1 리뷰에서 as-built로 확정).
K-apt 단가 공식: ㎡당 단가 = 항목 합계(세전) ÷ 총전용면적(당기 부과 세대의 exclusiveArea 합). 부가세는 별도 행으로 같은 공식(부가세 합계 ÷ 총전용면적)을 적용한다.
3. 불변식 4종 (vitest, src/composables/__tests__/useChargeSummaryTable.spec.js)
| # | 불변식 | 지키는 회계 정합 |
|---|---|---|
| ① | 각 유닛행 Σ항목셀 === invoiceFor(unit).summary.세전부과원금 | 총괄표(집계 뷰)와 고지서(개별 문서)가 항상 같은 세전 부과원금을 보여준다 — 관리사무소가 두 화면을 대조해도 불일치가 나올 수 없다. |
| ② | 각 항목열 합계 === byUnit(charge) totals 합 | 총괄표의 피벗 재구조화가 원본 배분 합계를 보존한다(FB-31 피벗 정합과 직결 — 이중 계상/누락 가드). |
| ③ | K-apt 항목별 합계 === ①뷰(세대×항목) 열 합계 | 뷰 3종이 서로 다른 숫자를 만들지 않는다 — 뷰는 투영(projection)일 뿐 별도 계산이 아니다. |
| ④ | ㎡당 단가 × 총전용면적 ≈ 합계(반올림 오차 허용) | K-apt 단가 계산이 원 단위 합계와 역산 가능하다 — 공시 서식 신뢰성. |
as-built 진단(2026-07-10, Task 1): 4개 불변식 모두 최초 구현에서 첫 실행 통과 — 실패 재현 사례 없음. 통과 근거는 구조적이다(①은 두 경로가 모두 activeCharges 기반 allocate() 합산이라 항등, ②는 cellMatrix()가 재계산 없이 g.total을 복사만 하므로 항등, ③④는 K-apt가 columnTotals()에서 파생되므로 자동 일치).
4. prior-only 세대 제외 사유 (⚠ 스코프 결정, 정본)
행 집합이 invoicingByUnit()(당기 배분 보유 유닛)로 제한된 것은 버그가 아니라 1차 스코프 결정이다:
- 총괄표는 "이번 차수에 무엇이 얼마씩 부과됐는가"를 보여주는 화면 — 당기 부과가 없는 세대는 이 질문의 대상이 아니다.
- prior-only 세대(전기이월 미수만 있음)의 미수 현황은 이미 미수금 연령분석(
useReceivableAging, 2026-06-29 정본화 완료) 화면이 별도로 담당한다 — 총괄표에 병합하면 "당기 부과"와 "누적 미수"라는 서로 다른 두 축이 한 표에 섞여 회계 의미가 흐려진다. - 화면 UI에 "당기 부과 세대 기준" 문구로 항상 정직하게 명기된다(ChargeSummaryView/MainOrg 양쪽).
- 후속으로 prior-only 세대까지 포함하려면, 별도 행 소스(
useReceivableAging의 유닛 축)를 병합하되 항목 셀은 공란(당기 부과 없음)으로 표시하는 확장이 필요 — 현재 컴포저블에는 그 병합 로직이 없다.
5. 확장 포인트
- prior-only 세대 포함: §4 참고.
unitRows()에invoicingByUnit()외에 미수 전용 유닛을 추가로 union하고cells를 빈 객체로 채우는 방식이 유력. - 부과내역서·수납현황: 같은 '보고서' 탭에 추가될 후속 문서.
useChargeSummaryTable자체는 총괄표 전용이므로, 새 보고서는 별도 컴포저블을 신설하되 이 문서의 "재계산 금지 — 기존 캐논 조합" 원칙을 그대로 따라야 한다. - K-apt 공시 연동(전송): 현재는 서식 뷰(화면 표시)만 제공한다 — 공시 시스템 API 전송은 스코프 밖. 연동이 필요해지면
kapt()의 반환 shape(rows[].{chargeCode,name,total,unitPrice}+vat/grandTotal/totalArea)를 그대로 API 페이로드로 직렬화하면 된다(추가 변환 불요 — 이미 K-apt 공시 항목 구조와 1:1 대응).