Skip to content

수납현황 집계 계약 — BE 참고

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


1. 개요

수납현황은 세대별 미수금의 roll-forward(전기이월 + 당기발생 − 회수 − 조정 = 차기이월)와 연체차수를 단지 1장으로 보여주는 read-only 문서다. 재계산은 일절 하지 않는다 — 기존 useReceivableAging(2026-06-29 정본화 완료, AR Aging)이 이미 산출한 행을 그대로 통과시키고 합계만 낼 뿐이다.

2. 데이터 정본 — useCollectionStatus

src/composables/useCollectionStatus(){ statusRows, statusTotals, formatAmount }.

js
const { agingByAxis } = useReceivableAging();
const { formatAmount } = useAllocations();

const statusRows = () => agingByAxis("unit");

function statusTotals(rows = statusRows()) {
  const sum = (key) => rows.reduce((s, r) => s + r[key], 0);
  return {
    전기이월: sum("전기이월"),
    당기발생: sum("당기발생"),
    회수: sum("회수"),
    조정: sum("조정"),
    차기이월: sum("차기이월"),
  };
}

2-1. 행 집합 = agingByAxis('unit') 정본 — 총괄표와 반대 방향

statusRows()useReceivableAging().agingByAxis('unit')가공 없이 그대로 통과시킨다. 이 행집합은 총괄표·부과내역서의 invoicingByUnit()(당기 부과 세대만)과 의도적으로 다르다agingByAxisuseForwarding(roll-forward 처리 엔진) 위의 read 집계 레이어로, 미수 잔액이 조금이라도 있는 모든 세대(prior-only 포함)를 포함한다.

toAgingRow(src/composables/useReceivableAging.js:18-32) 반환 shape:

{ key, name, 전기이월, 당기발생, 회수, 조정, 차기이월, 연체차수, anomalous, periods }
  • 전기이월 = r.forwarding(이월 잔액)
  • 당기발생 / 회수 / 조정 = 당기 차수(kind === 'current')의 billed / collected / adjusted만(전기이월이 이미 과거 차수 충당분을 반영하므로 중복차감 방지 — 회수·조정을 전체 기간 누적으로 합산하지 않는다)
  • 차기이월 = r.total(roll-forward 엔진의 최종 잔액)
  • 연체차수 = r.overdueCount
  • anomalous = fwd.isAnomalous(r) = r.overdueCount >= 2(src/composables/useForwarding.js:232)

2-2. statusTotals(rows) — 독립 재합산(단순 전달 아님)

rows 인자를 생략하면 내부에서 statusRows()를 다시 호출한다(총괄표 columnTotals와 동일 관용 — FE 호출 시 명시 전달 권장, FE handoff 참고). 각 열을 reduce독립 재합산한다 — useReceivableAging.agingSummary()가 이미 만든 합계를 전달받는 게 아니라 행에서 직접 다시 더하므로, 불변식②에서 agingSummary와 교차검증할 수 있다(전달이면 자기순환이라 검증 의미가 없음).

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

#불변식지키는 회계 정합
각 행 차기이월 === 전기이월 + 당기발생 − 회수 − 조정roll-forward 항등식이 문서 층에서도 깨지지 않음(엔진 값을 그대로 통과시켰다는 증거)
statusTotals() 각 열 === agingSummary('unit')의 총-접두 키(총전기이월·총당기발생·총회수·총조정·총차기이월)문서 합계행이 독립 소스(agingSummary)와 교차검증됨 — 별도 계산 경로가 아님
합계행 자체도 차기이월 === 전기이월 + 당기발생 − 회수 − 조정 충족 + rows.reduce(r => r.차기이월)과도 일치합계행이 "행별 합"과 "roll-forward 항등" 두 방향 모두에서 일관됨
행집합 === agingByAxis('unit').length(prior-only 세대 — 당기발생 0·전기이월>0 — 존재 가드)수납현황이 총괄표와 다른 행집합 정본(미수 보유 전 세대)을 쓴다는 설계 결정이 회귀로 깨지지 않음

as-built 진단(2026-07-10): 4개 불변식 모두 최초 구현에서 통과 — agingByAxis를 가공 없이 통과시키고 statusTotalsreduce 합산만 하는 얇은 레이어라 새 계산 경로가 없기 때문.

4. 엣지케이스

4-1. prior-only 세대 (당기발생 0·전기이월 > 0)

시드 데이터의 202호(부과종료)가 대표 사례 — 이번 차수 신규 부과가 없어(계약 종료 등) 당기발생 === 0이지만, 과거 미수가 남아있어 전기이월 > 0인 행이다. 총괄표·부과내역서의 invoicingByUnit() 행집합에는 이 세대가 없다(당기 배분 자체가 없으므로). 수납현황은 agingByAxis가 이 세대도 포함하므로 화면·인쇄 양쪽에 계속 노출된다 — 이는 버그가 아니라 "미수금이 남아있는 한 어느 화면에서도 누락되면 안 된다"는 설계 의도다(불변식④가 이 존재를 가드).

4-2. anomalous(연체 2차수 이상) 세대

fwd.isAnomalous(row) = row.overdueCount >= 2. 이 행은 삭제·필터되지 않고 그대로 남으며 합계에도 포함된다 — 화면에는 확인필요 배지(badge badge-sm badge-red-subtle)로만 표시하고 별도 처리를 강제하지 않는다(§useForwarding.js:104 주석: 당기 미수납만으로 오탐하지 않도록 overdueCount >= 2 임계값을 씀 — 당기 납기가 익월이라 asOf 시점에는 아직 미연체이기 때문).

4-3. "회수" 열은 채널 미분해

당기발생·회수·조정 모두 당기 차수 하나의 합산값이며, 회수가 어떤 수납 채널(계좌이체·카드·가상계좌 등)로 들어왔는지 분해하지 않는다. 채널별 분해는 별도 트랙(대사 3채널 — 나이스더빌·나이스페이·바로빌, docs/decisions/FEATURE-COLLECTING-RECONCILIATION-CHANNELS-2026-07-10.md, 파킹 중)에서 다룰 예정이며 본 문서 스코프 밖.

5. 확장 포인트

  • 인쇄: 단지 1장(세대 수만큼 세로로 길어짐)이라 세대수 비례 대량 인쇄 문제가 없다 — njk 파이프 수렴 대상 아님(문서류 매트릭스 §6 명기).
  • 채널별 회수 분해: §4-3 참고. useCollectionStatus회수 열을 채널별로 쪼개려면 useReceivableAging/useForwarding 층에 채널 축을 먼저 추가해야 한다(현재 재계산 금지 원칙상 이 문서 컴포저블 단독으로는 확장 불가).