Skip to content

수납 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)
           + seedAdvance
  • appliedLateFee 분은 연체료 충당으로 소진 → 원금 충당 가용에서 제외(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 재유도 행.

  1. billedPeriods를 label 오름차순(YYYY-MM) 정렬.
  2. asOfInflow(cc, asOf)fifoCollect 적용.
  3. 각 period의 outstanding = max(0, billed − adjusted − collected).
  4. overdue = !!dueDate && dueDate < asOf && outstanding > 0.
  5. 반환: { key, current, forwarding, total, periods, overdueCount }.

contractLateFeeAsOf(contractCode, billedPeriods, asOf) → number

interestEngine.computeLateFeespayments ≤ asOf 슬라이스로 직접 호출하여 그 시점 연체료 재산출.

  • payments = receipts(receivedAt ≤ asOf, status ≠ '취소', contractCode 일치)에서 appliedBreakdown의 period별 { date, principalApplied } 배열.
  • dueDate·billed > 0 차수만 installment로 변환.
  • computeLateFees(installments, asOf, policy) 호출 → .total.

unitRowsAsOf() → UnitRow[]

유닛 = 소속 계약 합산.

  • isPast = falseforwardingByUnit() 위임(무변경).
  • isPast = true → 각 유닛의 unitOccupants(unitCode) 계약 코드 집합 추출 → contractRowAsOf × N → total/current/forwarding/lateFee 합산.
  • overdueCount = 소속 계약 중 최대값(연체 차수 수 합이 아닌 max — 경미한 차이, 하단 한계 참조).

contractRowsAsOf() → ContractRow[]

계약 탭 as-of 재유도 행 목록(atom flat).

  • isPast = falseforwardingByContract() 위임(무변경).
  • isPast = trueforwardingByContract()의 각 계약에 대해 contractRowAsOf(contractCode, billedPeriods, asOf) 직접 적용.
  • 계약 탭은 집합 합산이 없어 atom 1:1 → contractRowAsOf 직접 매핑.
  • overdueCountcontractRowAsOf의 반환값을 그대로 사용(합산 없음).

memberRowsAsOf() → MemberRow[]

멤버 탭 as-of 재유도 행 목록(유닛 탭과 동형 집합 구조).

  • isPast = falseforwardingByMember() 위임(무변경).
  • isPast = true → 각 멤버의 memberHoldings(memberCode) 계약 코드 집합 추출 → contractRowAsOf × N → total/current/forwarding/lateFee 합산. unitRowsAsOf에서 unitOccupantsmemberHoldings로 소스만 교체한 패턴.
  • 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.buildOneasOf 인자를 받아 차수별 미수·상태·수납 내역을 재계산한다.

  1. 계약(contract) 축: contractRowAsOf(cc, row.periods, asOf) 직접 호출 → 차수별 outstanding·collected 재산정. 반환값의 asofByLabel 맵으로 각 period의 outstanding·collected·status를 덮어쓴다.
  2. 비-계약 축(unit/member — prior-only 폴백): contractRowAsOf를 거치지 않고 원래 forwarding 값 유지(비-캐논 행은 asOf 재산정 대상 아님).
  3. payments 필터: receipts.filter(r => r.receivedAt ≤ asOf) — 기준일 이후 수납은 수납내역에서 제외.
  4. 연체료: lateFeesFor(feeAxis, feeKey, asOf) 호출 — 기존 contractLateFeeAsOf와 동일 경로로 그 시점 연체료 금액 산출.

prior-only 제외

buildOnefeeAxis !== 'contract' 이면서 contractRowAsOf를 거치지 않는 prior-only 폴백 경로(활성 배분 line 없는 부과종료 유닛·멤버)는 현재 기준일(present) 값을 그대로 반환한다. asOf 재산정이 적용되지 않는 경우이다. 데모 시드에는 미발현.


불변식

  1. 현재 기준일 동치(목록): asOf === REFERENCE_DATEunitRowsAsOf() = forwardingByUnit() 전수 동치.
    • spec 검증: useCollectingAsOf.spec.js — "기준일=현재면 forwardingByUnit total과 동치".
  2. 현재 기준일 동치(상세): asOf === REFERENCE_DATEbuildOne 차수별 outstanding·status가 forwarding 원본과 동치(per-period 검증됨).
  3. 과거 모드 미수 ≥ 현재: 기준일 이후 수납을 제외하므로 미수는 현재보다 크거나 같다.
  4. billed·adjusted는 날짜 무관: forwardingByContract에서 청구·감면 값을 재사용(재유도 대상 아님).
  5. FIFO 불변: 분할·순서에 무관하게 동일 기준일 inflow → 동일 충당 결과.

알려진 한계 (BE 모델 필요)

  1. prior-only 유닛 (부과 종료, unitOccupants 빈 배열): 과거 모드에서 해당 유닛의 total = 0. unitOccupants가 활성 배분 line 기반이라 종료 계약은 잡히지 않음. 상세 시트(buildOne prior-only 폴백 경로)에도 동일하게 asOf 재산정이 적용되지 않음. 근본 해결: 종료 계약도 unit→contractCode 매핑을 BE 모델에 보존해야 함(활성 여부 무관). 현재 데모 시드에는 미발현.
  2. billed 날짜 무관: billedPeriods는 기준일 필터 없이 포함. 기준일이 당기 청구일 이전이어도 당기 차수가 목록·상세 모두에 표시됨. 실 BE에서 billedAt ≤ asOf 필터 추가 필요.
  3. adjusted(감면) 날짜 없음: 현재 adjusted 값에 적용 날짜가 없어 기준일 이전·이후 구분 불가 — 현재 감면 값이 과거 모드 목록·상세에도 그대로 적용됨. 실 BE에서 감면 이력에 adjustedAt 필드 추가 필요.
  4. 가수금(미식별 풀) as-of — 의도적 비목표(WONTFIX·프로토타입): 가수금은 특정 계약에 미식별 상태인 별도 보유 풀이라 어느 계약의 미수도 오염시키지 않는다(미수·연체료 as-of 숫자는 가수금과 무관하게 정확). 과거 뷰에 "그때 가수금 잔고"가 표시되지 않을 뿐이며, 가치 대비 비용이 낮아 프로토타입 범위에서 의도적으로 제외한다. 실 BE에서 시점 가수금 잔고가 필요하면 가수금 원장에 at 날짜를 두고 ≤ asOf로 재합산(선수금 as-of와 동일 패턴). ※ 선수금(advance)은 as-of 반영됨buildOne이 advance ledger를 at ≤ asOf signed-delta 합산으로 과거 잔액 산출(상세 시트 선수금 잔액·원장 시점 일관). 가수금만 비목표.
  5. 적용 탭 = 유닛·계약·멤버 전체: 조회 기준일은 수납 화면의 세 탭 모두에 적용됨. 계약 탭은 atom flat(contractRowsAsOf), 멤버 탭은 유닛과 동형 집합(memberRowsAsOf). 상세 시트도 동일 asOf로 연동됨.
  6. overdueCount 과거 모드: 소속 계약 중 max 값을 유닛에 채택(합산 아님). 승계 유닛(복수 계약 연체)에서 실제보다 낮을 수 있음 — 경미, 실 BE에서 합산으로 정정 가능.
  7. 연체료 금액 목록 미표시 → 상세 시트에서 표시: 유닛·계약·멤버 목록 행에는 연체료 금액 컬럼이 없다(present 모드도 마찬가지). 단, 과거 기준일 상태에서 계약명 클릭 시 열리는 상세 시트에서 그 시점 연체료 금액이 표시된다(lateFeesFor(feeAxis, feeKey, asOf) 경로 — buildOne asOf 연동으로 구현 완료).

실서비스 메모

  • asOfInflowrecordReceipt 의 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보다 과대(불변식 위반). contractLateFeeAsOf export(테스트용). 테스트: 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/_fwdMember PRIOR_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·환급→감소.