다크모드
수납 as-of 타임머신(유닛·계약·멤버) — BE 참고
독자: BE 개발자/AI. 조회 기준일(as-of) 기준 미수·연체료 재유도 계약·불변식·알려진 한계. 상세 시트 as-of 포함. 정본 spec:
docs/superpowers/specs/2026-06-24-collecting-asof-timemachine-design.md. 관련: [[collecting-detail.md]] · [[collecting-aggregate-fullpay.md]] · [[tenancy-axes-billing.md]].
개요
수납 화면(유닛·계약·멤버 탭) 우측 상단 조회 기준일(asOf) DatePicker를 과거 날짜로 설정하면, 그 시점의 미수·연체료를 forwarding 영속 상태에 손대지 않고 read-model 재유도로 산출한다. 현재 기준일(REFERENCE_DATE)이면 forwardingByUnit(기존 위임) 그대로 — 차이 없음. asOf는 모듈 싱글톤 ref이므로 탭을 전환해도 기준일이 유지된다.
핵심 함수 (src/composables/useCollectingAsOf.js)
asOfInflow(contractCode, asOf) → number
기준일까지 그 계약에 들어온 가용 원금 합산.
asOfInflow = Σ receipts(receivedAt ≤ asOf, status ≠ '취소', identified) (amount − appliedLateFee)
+ seedAdvanceappliedLateFee분은 연체료 충당으로 소진 → 원금 충당 가용에서 제외(principalCash).seedAdvance= 비-영수증 선수금 적립(advanceLedger 적립 ≤ asOf) − 영수증 toAdvance 중복 제거.- 취소·미식별(identified=false) 영수증 제외.
fifoCollect(periods, amount) → Map<label, collectedAmount>
청구 차수(periods) 목록과 가용 원금(amount)을 받아 오래된 차수부터 FIFO 충당.
due = billed − adjusted(감면 차감).- 잔여가 0이 되거나 차수가 소진되면 종료.
- 반환 맵:
{ '2025-08': 10000, '2025-12': 5000 }— 누락된 label은 0원 충당.
contractRowAsOf(contractCode, billedPeriods, asOf) → ContractRow
계약 단위 as-of 재유도 행.
billedPeriods를 label 오름차순(YYYY-MM) 정렬.asOfInflow(cc, asOf)→fifoCollect적용.- 각 period의
outstanding = max(0, billed − adjusted − collected). overdue = !!dueDate && dueDate < asOf && outstanding > 0.- 반환:
{ key, current, forwarding, total, periods, overdueCount }.
contractLateFeeAsOf(contractCode, billedPeriods, asOf) → number
interestEngine.computeLateFees를 payments ≤ asOf 슬라이스로 직접 호출하여 그 시점 연체료 재산출.
payments= receipts(receivedAt ≤ asOf, status ≠ '취소', contractCode 일치)에서appliedBreakdown의 period별{ date, principalApplied }배열.- dueDate·billed > 0 차수만 installment로 변환.
computeLateFees(installments, asOf, policy)호출 →.total.
unitRowsAsOf() → UnitRow[]
유닛 = 소속 계약 합산.
isPast = false→forwardingByUnit()위임(무변경).isPast = true→ 각 유닛의unitOccupants(unitCode)계약 코드 집합 추출 →contractRowAsOf× N → total/current/forwarding/lateFee 합산.overdueCount= 소속 계약 중 최대값(연체 차수 수 합이 아닌 max — 경미한 차이, 하단 한계 참조).
contractRowsAsOf() → ContractRow[]
계약 탭 as-of 재유도 행 목록(atom flat).
isPast = false→forwardingByContract()위임(무변경).isPast = true→forwardingByContract()의 각 계약에 대해contractRowAsOf(contractCode, billedPeriods, asOf)직접 적용.- 계약 탭은 집합 합산이 없어 atom 1:1 →
contractRowAsOf직접 매핑. overdueCount는contractRowAsOf의 반환값을 그대로 사용(합산 없음).
memberRowsAsOf() → MemberRow[]
멤버 탭 as-of 재유도 행 목록(유닛 탭과 동형 집합 구조).
isPast = false→forwardingByMember()위임(무변경).isPast = true→ 각 멤버의memberHoldings(memberCode)계약 코드 집합 추출 →contractRowAsOf× N → total/current/forwarding/lateFee 합산.unitRowsAsOf에서unitOccupants→memberHoldings로 소스만 교체한 패턴.overdueCount= 소속 계약 중 최대값(유닛과 동일 정책).
excludedReceiptCount(axis, key) → number
기준일 이후 해당 집계 단위에 들어온 유효 영수증 건수. 축 일반화 버전.
axis: 'unit' | 'member' | 'contract'key: 축별 코드(unitCode / memberCode / contractCode).axis === 'unit'→ 해당 유닛 소속 계약 코드 집합의 영수증 합산(기존 동작).axis === 'member'→ 해당 멤버 소속 계약 코드 집합의 영수증 합산(유닛 동형).axis === 'contract'→ 해당 계약 코드 단일 영수증 건수.
기존
excludedReceiptCount(unitCode)시그니처는(axis='unit', key=unitCode)로 후방 호환 가능하나, 신규 코드는 2-인자 형태를 사용한다.
setAsOf(iso) / resetToPresent()
setAsOf: 미래 날짜는 REFERENCE_DATE로 클램프.- 모듈 싱글톤
ref— 유닛 탭 DataToolBar·DynamicTableOrg 공유.
상세 시트 as-of 연동 (useCollectingDetail.buildOne)
목록 조회 기준일이 과거이면 계약명(호실·계약자) 클릭 시 열리는 수납 상세 시트도 동일 asOf로 재산정된다.
buildOne(feeAxis, feeKey, asOf) as-of 동작
useCollectingDetail.buildOne은 asOf 인자를 받아 차수별 미수·상태·수납 내역을 재계산한다.
- 계약(contract) 축:
contractRowAsOf(cc, row.periods, asOf)직접 호출 → 차수별outstanding·collected재산정. 반환값의asofByLabel맵으로 각 period의outstanding·collected·status를 덮어쓴다. - 비-계약 축(unit/member — prior-only 폴백):
contractRowAsOf를 거치지 않고 원래 forwarding 값 유지(비-캐논 행은 asOf 재산정 대상 아님). - payments 필터:
receipts.filter(r => r.receivedAt ≤ asOf)— 기준일 이후 수납은 수납내역에서 제외. - 연체료:
lateFeesFor(feeAxis, feeKey, asOf)호출 — 기존contractLateFeeAsOf와 동일 경로로 그 시점 연체료 금액 산출.
prior-only 제외
buildOne이 feeAxis !== 'contract' 이면서 contractRowAsOf를 거치지 않는 prior-only 폴백 경로(활성 배분 line 없는 부과종료 유닛·멤버)는 현재 기준일(present) 값을 그대로 반환한다. asOf 재산정이 적용되지 않는 경우이다. 데모 시드에는 미발현.
불변식
- 현재 기준일 동치(목록):
asOf === REFERENCE_DATE→unitRowsAsOf()=forwardingByUnit()전수 동치.- spec 검증:
useCollectingAsOf.spec.js— "기준일=현재면 forwardingByUnit total과 동치".
- spec 검증:
- 현재 기준일 동치(상세):
asOf === REFERENCE_DATE→buildOne차수별outstanding·status가 forwarding 원본과 동치(per-period 검증됨). - 과거 모드 미수 ≥ 현재: 기준일 이후 수납을 제외하므로 미수는 현재보다 크거나 같다.
- billed·adjusted는 날짜 무관:
forwardingByContract에서 청구·감면 값을 재사용(재유도 대상 아님). - FIFO 불변: 분할·순서에 무관하게 동일 기준일 inflow → 동일 충당 결과.
알려진 한계 (BE 모델 필요)
- prior-only 유닛 (부과 종료, unitOccupants 빈 배열): 과거 모드에서 해당 유닛의
total = 0.unitOccupants가 활성 배분 line 기반이라 종료 계약은 잡히지 않음. 상세 시트(buildOneprior-only 폴백 경로)에도 동일하게 asOf 재산정이 적용되지 않음. 근본 해결: 종료 계약도 unit→contractCode 매핑을 BE 모델에 보존해야 함(활성 여부 무관). 현재 데모 시드에는 미발현. - billed 날짜 무관:
billedPeriods는 기준일 필터 없이 포함. 기준일이 당기 청구일 이전이어도 당기 차수가 목록·상세 모두에 표시됨. 실 BE에서billedAt ≤ asOf필터 추가 필요. - adjusted(감면) 날짜 없음: 현재
adjusted값에 적용 날짜가 없어 기준일 이전·이후 구분 불가 — 현재 감면 값이 과거 모드 목록·상세에도 그대로 적용됨. 실 BE에서 감면 이력에adjustedAt필드 추가 필요. - 가수금(미식별 풀) as-of — 의도적 비목표(WONTFIX·프로토타입): 가수금은 특정 계약에 미식별 상태인 별도 보유 풀이라 어느 계약의 미수도 오염시키지 않는다(미수·연체료 as-of 숫자는 가수금과 무관하게 정확). 과거 뷰에 "그때 가수금 잔고"가 표시되지 않을 뿐이며, 가치 대비 비용이 낮아 프로토타입 범위에서 의도적으로 제외한다. 실 BE에서 시점 가수금 잔고가 필요하면 가수금 원장에
at날짜를 두고≤ asOf로 재합산(선수금 as-of와 동일 패턴). ※ 선수금(advance)은 as-of 반영됨 —buildOne이 advance ledger를at ≤ asOfsigned-delta 합산으로 과거 잔액 산출(상세 시트 선수금 잔액·원장 시점 일관). 가수금만 비목표. - 적용 탭 = 유닛·계약·멤버 전체: 조회 기준일은 수납 화면의 세 탭 모두에 적용됨. 계약 탭은 atom flat(
contractRowsAsOf), 멤버 탭은 유닛과 동형 집합(memberRowsAsOf). 상세 시트도 동일 asOf로 연동됨. - overdueCount 과거 모드: 소속 계약 중 max 값을 유닛에 채택(합산 아님). 승계 유닛(복수 계약 연체)에서 실제보다 낮을 수 있음 — 경미, 실 BE에서 합산으로 정정 가능.
- 연체료 금액 목록 미표시 → 상세 시트에서 표시: 유닛·계약·멤버 목록 행에는 연체료 금액 컬럼이 없다(present 모드도 마찬가지). 단, 과거 기준일 상태에서 계약명 클릭 시 열리는 상세 시트에서 그 시점 연체료 금액이 표시된다(
lateFeesFor(feeAxis, feeKey, asOf)경로 —buildOneasOf 연동으로 구현 완료).
실서비스 메모
asOfInflow↔recordReceipt의 forwarding 집계는 상호 독립(read-model vs write-model). as-of 조회가 forwarding 상태를 변경하지 않음.- 실 BE는
receipts테이블에received_at,status,identified,applied_late_fee,applied_breakdown컬럼이 있어야 동일 계약 재구성이 가능. - 연체료 재산출(
contractLateFeeAsOf)은 차수별 installment × 기준일 슬라이스 payments로interestEngine을 직접 호출 — BE에선 동일 로직을 SQL 집계 또는 서비스 계층에서 구현.
2026-06-26 — 3차 감사 수정 (T6)
- as-of 연체료 발생중지 동결 (
useCollectingAsOf.contractLateFeeAsOf): present 경로(useLateFee.lateFeesFor)와 동일하게computeLateFees호출 전effectiveAsOf(at, policy.accrualPause)로 기준일을 동결(발생중지 창 내 asOf→startDay-1). 누락 시 발생중지 enabled 단지에서 창 내 기준일의 as-of 연체료가 present보다 과대(불변식 위반).contractLateFeeAsOfexport(테스트용). 테스트:useCollectingAsOf.spec.js(창 내=동결일 동일).
2026-06-26 — 4차 감사 수정 (Q1·Q4)
- Q1 prior-only 축 as-of 폴백 (
useCollectingAsOf.unitRowsAsOf/memberRowsAsOf): 당기 라인 없는 prior-only(부과종료·중단) 유닛/멤버는ccs=[]라 미수가 0으로 소실됐다(계약축·present와 drift, 불변식 #8).ccs비면 자기 행contractRowAsOf(key, u.periods, asOf)로 재계산(present_fwdUnit/_fwdMemberPRIOR_PERIODS 폴백과 동형). lateFee도 동일. 테스트:useCollectingAsOf.spec.js(c-end/202/m-end 축 정합). - Q4 asOfInflow 취소·환급 대칭 (
useCollectingAsOf.asOfInflow): seedAdvance가 ledger '적립'만 합산해 취소·환급 차변을 무시 → 취소된 과오납이 과거 시점 미수를 유령 완납. 수정: ①allReceiptsToAdvance=모든 영수증(취소 포함) toAdvance 합으로 취소 영수증의 잔존 '적립' 상쇄 ②refundOut(환급 cash-out, 음수) 차감. 취소=적립상쇄·환급=차감·충당=내부이동(미차감). 불변식 #5(취소 대칭)·#7(as-of 순수성). 테스트: 취소→0·환급→감소.