Skip to content

수납 as-of 타임머신(유닛·계약·멤버) — FE 메인테이너

독자: FE 메인테이너/AI. 조회 기준일 공유 상태·DataToolBar 컨트롤·DynamicTableOrg 소비·과거 읽기전용 게이팅·배지/주석·계약/멤버 탭 소비·상세 시트 as-of 캡처·확장 포인트. 정본 spec: docs/superpowers/specs/2026-06-24-collecting-asof-timemachine-design.md. 관련: [[collecting-aggregate-fullpay.md]] · [[tenancy-axes-billing-tables.md]] · [[collecting-detail-sheet.md]].


파일 위치

역할파일
핵심 컴포저블src/composables/useCollectingAsOf.js
순수 함수 (tree-shakeable)asOfInflow, fifoCollect (동일 파일에서 named export)
DataToolBar (DatePicker + [현재로]) — 유닛src/components/billing/_core/console/collecting/unit/blocks/DataToolBarOrg.vue
유닛 탭 테이블 (as-of 소비·게이팅)src/components/billing/_core/console/collecting/unit/blocks/DynamicTableOrg.vue
DataToolBar (DatePicker + [현재로]) — 멤버src/components/billing/_core/console/collecting/member/blocks/DataToolBarOrg.vue
멤버 탭 테이블 (as-of 소비·게이팅)src/components/billing/_core/console/collecting/member/blocks/DynamicTableOrg.vue
DataToolBar (DatePicker + [현재로]) — 계약src/components/billing/_core/console/collecting/contract/blocks/DataToolBarOrg.vue
계약 탭 테이블 (as-of 소비·게이팅)src/components/billing/_core/console/collecting/contract/blocks/DynamicTableOrg.vue
테스트src/composables/__tests__/useCollectingAsOf.spec.js

공유 asOf 상태

useCollectingAsOf모듈 싱글톤 ref(파일 최상위 const asOf = ref(REFERENCE_DATE))를 사용한다. DataToolBar와 DynamicTableOrg가 같은 컴포저블을 호출하면 동일 ref를 공유한다. 별도 provide/inject 불필요. 탭을 전환해도 싱글톤 ref가 유지되므로 기준일은 유닛·계약·멤버 탭 전환 사이에서 초기화되지 않는다.

js
// DataToolBarOrg.vue (유닛·계약·멤버 탭 공통)
const { asOf, isPast, setAsOf, resetToPresent } = useCollectingAsOf();

// DynamicTableOrg.vue — 유닛 탭
const { isPast, asOf, unitRowsAsOf, excludedReceiptCount } = useCollectingAsOf();

// DynamicTableOrg.vue — 계약 탭
const { isPast, asOf, contractRowsAsOf, excludedReceiptCount } = useCollectingAsOf();

// DynamicTableOrg.vue — 멤버 탭
const { isPast, asOf, memberRowsAsOf, excludedReceiptCount } = useCollectingAsOf();

asOf 공개 API

이름타입설명
asOfRef<string>현재 기준일 (YYYY-MM-DD)
isPastComputedRef<boolean>asOf < REFERENCE_DATE
setAsOf(iso)function설정 — 미래 날짜는 REFERENCE_DATE로 클램프
resetToPresent()functionasOf → REFERENCE_DATE
unitRowsAsOf()function → UnitRow[]현재=forwardingByUnit 위임 / 과거=계약 합산 재유도
contractRowsAsOf()function → ContractRow[]현재=forwardingByContract 위임 / 과거=contractRowAsOf 직접 매핑(atom flat)
memberRowsAsOf()function → MemberRow[]현재=forwardingByMember 위임 / 과거=memberHoldings 기반 계약 합산(유닛 동형)
excludedReceiptCount(axis, key)function → number기준일 이후 해당 집계 단위 유효 수납 건수. axis: 'unit'|'member'|'contract', key: 해당 코드

DataToolBar 컨트롤 (DataToolBarOrg.vue)

html
<div class="flex flex-row items-center gap-x-2">
  <span class="body-xs text-dimmed whitespace-nowrap">조회 기준일</span>
  <DatePicker v-model="asOfDate" />
  <button
    v-if="isPast"
    @click="resetToPresent"
    class="button button-neutral-subtle button-sm whitespace-nowrap"
  >
    현재로
  </button>
</div>
  • asOfDate = computed 양방향: get → parseDate(asOf.value) / set → setAsOf(YYYY-MM-DD).
  • [현재로] 버튼은 v-if="isPast" — 과거 모드일 때만 노출.
  • 일괄 수납 버튼(:disabled="isPast")도 DataToolBar에서 같이 게이팅.

DynamicTableOrg as-of 소비

collectings 행 분기

js
const collectings = computed(() => {
  if (isCanon.value) {
    const rows = isPast.value
      ? unitRowsAsOf().map(r => ({ ...r, id: 'fwd-' + r.key, ... }))
      : buildCanonRows()
    ...
  }
})

현재 모드(isPast = false) → buildCanonRows() (forwardingByUnit 위임). 과거 모드(isPast = true) → unitRowsAsOf() (as-of 재유도).

펼침 계약 행 as-of 미수

js
const contractAsOfOutstanding = (cc) => {
  if (!isPast.value) return null
  const row = forwardingByContract().find(r => r.key === cc)
  if (!row) return 0
  const ordered = [...row.periods].sort(...)
  const collected = fifoCollect(ordered, asOfInflow(cc, asOf.value))
  return ordered.reduce((s, p) => s + max(0, billed − adjusted − collected[p.label]), 0)
}

펼침 행 총청구금 셀: formatNumber(isPast ? (contractAsOfOutstanding(m.contractCode) ?? m.amount) : m.amount).

제외 건수 집계

js
const excludedTotal = computed(() =>
  isPast.value
    ? collectings.value.reduce((s, r) => s + (r.key ? excludedReceiptCount(r.key) : 0), 0)
    : 0,
);

배지 및 주석

html
<div v-if="isCanon && isPast" class="w-full flex items-center gap-x-2 flex-wrap">
  <span class="badge badge-md badge-warning-moderate rounded-full">
    조회 기준일 {{ asOf }} · 과거 시점(읽기전용)
  </span>
  <span v-if="excludedTotal > 0" class="body-xs text-dimmed">
    기준일 이후 수납 {{ excludedTotal }}건 제외됨
  </span>
</div>
  • 테이블 위 전체 폭 flex 행 — isCanon && isPast 일 때만 렌더.
  • 배지: badge-warning-moderate (주의 색조).
  • 제외 건수 주석: excludedTotal > 0 일 때만 추가 표시.

과거 읽기전용 게이팅 — 3진입점

과거 모드(isPast = true)에서 수납 액션을 막는 곳이 세 군데 있다. 모두 :disabled="isPast" 또는 클릭 핸들러 가드.

1. 집합 행 [완납]

html
<button
  ...
  :disabled="collecting.totalOutstanding <= 0 || isPast"
  class="button button-primary-subtle button-xs"
>
  완납
</button>

isPast OR totalOutstanding <= 0 이면 비활성.

2. 펼침 계약 행 [수납] 버튼 + Enter 확정

html
<button
  v-else
  @click="openContractInline(m)"
  :disabled="contractOutstanding(m.contractCode) <= 0 || isPast"
  class="button button-primary-subtle button-xs"
>
  수납
</button>

열린 인라인 입력 상태에서도 Enter·✓ 버튼에 isPast 가드:

html
<input ... :disabled="isPast" @keydown.enter.prevent="isPast ? null : commitContractInline(m)" />
<button @click="isPast ? null : commitContractInline(m)" :disabled="isPast" ...></button>

3. 일괄 완납 [선택 완납]

html
<button
  @click="commitBatch()"
  :disabled="!selectedRows.length || isPast"
  class="button button-primary-bold button-sm"
>
  선택 완납
</button>

계약 탭 as-of 소비

계약 탭은 atom flat 구조(합산 없음). contractRowsAsOf()forwardingByContract()의 소스 스왑 역할을 한다.

js
// 계약 탭 DynamicTableOrg.vue
const { isPast, asOf, contractRowsAsOf, excludedReceiptCount } = useCollectingAsOf()

const collectings = computed(() => {
  if (isCanon.value) {
    return isPast.value ? contractRowsAsOf() : buildCanonContractRows()
  }
  ...
})

계약 탭 과거 읽기전용 게이팅

계약 탭은 집합 행이 없으므로 유닛/멤버의 [완납]·일괄 완납 게이팅 대신 atom 인라인 수납만 잠근다.

진입점게이팅
atom 행 [수납] 버튼`:disabled="...
인라인 input Enter 확정@keydown.enter.prevent="isPast ? null : commitContractInline(m)"
인라인 ✓ 버튼@click="isPast ? null : commitContractInline(m)" :disabled="isPast"
계약명 클릭 → 상세 시트과거에도 열림 허용 — 시트가 useCollectingActions를 통해 목록 asOf를 고정 캡처하여 읽기전용으로 표시

멤버 탭 as-of 소비

멤버 탭은 유닛 탭과 동형 — 집합 [완납]·펼침 [수납]·인라인·일괄 완납 전부 isPast 잠금.

js
// 멤버 탭 DynamicTableOrg.vue
const { isPast, asOf, memberRowsAsOf, excludedReceiptCount } = useCollectingAsOf()

const collectings = computed(() => {
  if (isCanon.value) {
    const rows = isPast.value
      ? memberRowsAsOf().map(r => ({ ...r, id: 'fwd-' + r.key, ... }))
      : buildCanonMemberRows()
    ...
  }
})

memberRowsAsOf()unitRowsAsOf()와 소스(memberHoldings vs unitOccupants)만 다르고 집계 로직·반환 구조·게이팅 패턴은 동일하다. 유닛 탭 DynamicTableOrg 코드를 템플릿으로 재사용할 수 있다.

멤버 탭 excludedReceiptCount 소비

js
const excludedTotal = computed(() =>
  isPast.value
    ? collectings.value.reduce((s, r) => s + (r.key ? excludedReceiptCount("member", r.key) : 0), 0)
    : 0,
);

유닛 탭은 excludedReceiptCount('unit', r.key), 멤버 탭은 excludedReceiptCount('member', r.key), 계약 탭은 excludedReceiptCount('contract', r.key).


반응성 흐름

DataToolBar setAsOf(iso)  [유닛·계약·멤버 탭 공통 — 싱글톤]
  → asOf (ref, 모듈 싱글톤) 변경
  → isPast (computed) 재평가
  → collectings (computed, 각 탭 DynamicTableOrg) — isPast.value 분기 재실행
      유닛: unitRowsAsOf() / 계약: contractRowsAsOf() / 멤버: memberRowsAsOf()
  → excludedTotal (computed) — isPast 분기 재실행 (axis별 excludedReceiptCount 호출)
  → 배지 v-if 토글
  → 게이팅 :disabled 바인딩 재평가

별도 watch 없이 모두 computed 의존성 체인으로 자동 반응. 탭 전환 시 각 탭의 DynamicTableOrg가 독립적으로 같은 싱글톤 asOf를 구독하므로 기준일이 초기화되지 않는다.


테스트 (useCollectingAsOf.spec.js)

케이스검증
기준일=현재 동치unitRowsAsOf().total === forwardingByUnit().total 전수
과거 기준일 미수 증가기준일 이후 수납 recordReceipt → asofTotal ≥ presentTotal
isPast + 미래 클램프setAsOf('2099-01-01') → asOf === REFERENCE_DATE, isPast === false
excludedReceiptCount기준일 이후 수납 1건 → excludedReceiptCount('101') ≥ 1
FIFO 충당2차수 15000원 → 오래된 차수 full, 나머지 차수 partial
asOfInflow 날짜 슬라이스11-30 기준 → 12-20 수납 제외 / 취소·미식별 제외 / appliedLateFee 차감

상세 시트 as-of 연동 (useCollectingActions + SheetCollectingDetailArt)

asOf 고정 캡처(seed) + readOnly

useCollectingActions(selectionRef)selectedCollecting(현재 선택 행)을 받는다. 계약명 클릭으로 시트가 열릴 때(selectionRef 변경 watch) 그 순간 목록의 isPast·listAsOf를 고정 캡처한다.

js
// src/composables/useCollectingActions.js
const asOf = ref(REFERENCE_DATE);
const readOnly = ref(false);

const { asOf: listAsOf, isPast } = useCollectingAsOf();

watch(selectionRef, () => {
  readOnly.value = isPast.value;
  asOf.value = isPast.value ? listAsOf.value : REFERENCE_DATE;
});
  • readOnly = true → 목록이 과거 모드일 때만(시트 열린 후 목록 날짜를 바꿔도 시트 readOnly 불변).
  • asOf = listAsOf.value → 목록 기준일 그대로 detail computed에 전달 → useCollectingDetail.detailFor(axis, key, asOf)buildOne as-of 재산정.
  • REFERENCE_DATE 복구 명칭: 구 DEMO_AS_OFREFERENCE_DATE로 일원화(현재 적용됨).

detail 파생

js
const detail = computed(() => {
  const sel = selectionRef.value;
  if (!sel) return null;
  return detailFor(sel.axis, sel.key, asOf.value);
});

asOf.value가 과거이면 buildOnecontractRowAsOf 경로로 차수별 outstanding·collected·status·연체료를 재산정한다(BE 문서 참조).

상세 시트 읽기전용 게이팅 (SheetCollectingDetailArt.vue)

readOnly prop은 아래 네 지점에서 소비된다.

컴포넌트 / 요소게이팅 방식효과
CollectingActionOrgv-if="!readOnly"수납 확정 패널 전체 숨김
AdvanceBalanceOrg:read-only="readOnly"선수금 자동충당 토글 잠금
ReceiptHistoryOrg:read-only="readOnly"수납 취소·환급 버튼 잠금
ReceivableAdjustmentsOrg:read-only="readOnly"조정 승인·취소 버튼 잠금

과거 시점 배지는 시트 zone-top에 표시:

html
<div v-if="readOnly" class="badge badge-sm badge-warning-moderate rounded-full">
  조회 기준일 {{ asOf }} · 과거 시점(읽기전용)
</div>

확장 포인트

  • 계약 탭 ✅ 구현 완료: contractRowsAsOf() named export + 계약 탭 DynamicTableOrg 소비. atom 인라인만 잠금, 계약명 클릭 시트는 과거에도 열림.
  • 멤버 탭 ✅ 구현 완료: memberRowsAsOf() named export + 멤버 탭 DynamicTableOrg 소비. 유닛 탭 미러(집합 [완납]·펼침 [수납]·인라인·일괄 전부 isPast 잠금).
  • 상세 시트 as-of 연동 ✅ 구현 완료: useCollectingActions watch(selectionRef) → asOf 고정 캡처·readOnly=true. SheetCollectingDetailArt 4지점 게이팅. 연체료 금액 포함 그 시점 상세 표시.
  • 기준일 URL 동기화: setAsOf를 router query 파라미터와 연동하면 URL 공유 가능. 싱글톤 ref를 URL 기반 초기화로 대체하거나 watchEffect로 동기화.
  • DatePicker 범위 제한: 현재는 미래 날짜를 setAsOf 내부에서 클램프. UI DatePicker에 maxValue prop을 REFERENCE_DATE로 설정하면 선택 불가로 개선 가능.
  • prior-only 계약 처리: 멤버·유닛 탭 모두 memberHoldings/unitOccupants가 활성 배분 line 기반이므로 부과 종료 계약은 과거 모드에서 잡히지 않음. BE 모델에서 비활성 계약 매핑 보존 필요(한계 §1 참조).