다크모드
차수 체인 · 마감 · 이월 — FE 메인테이너 매뉴얼
대상 독자: FE 메인테이너/AI. 관리비 상용화 "차수 체인" 트랙(2026-07-11) 산출물. 백엔드/데이터 계약은
docs/handoff/backend/billing-period-chain.md참고 — 본 문서는 소비 측(컴포넌트·라우팅·테스트)에 집중한다.
1. 한눈에 — 무엇이 바뀌었나
useBillingPeriod가 단일 엔트리에서 체인(entries + currentIndex)으로 진화하면서, 이전에는 하드코딩 상수(REFERENCE_DATE/CURRENT_PERIOD)였던 "오늘/이번 달"이 체인에서 파생하는 값으로 바뀌었다. 마감(usePeriodClose)을 실행하면 이 앵커가 실제로 전진하고, 그 결과가 헤더·연령분석·콘솔개요 등 전 화면에 즉시 반영된다. 신규 컴포넌트 3개(ActionBarOrg/DialogClosePeriodArt/PeriodHistoryOrg+SheetClosedPeriodArt)가 마감 UI를 담당한다.
useBillingPeriod(체인)
└─ periodOf/periodsOf/updatePeriod/closeCurrentPeriod ── 순수 (line,edition) API
└─ billingAnchor.js (currentAsOf/currentLabel/currentDueDate) ── 8개 컴포저블이 소비
└─ usePeriodClose.closePeriod() ── 마감 오케스트레이터(재계산 없음, 캐논 값 고정만)
└─ UI: ActionBarOrg → DialogClosePeriodArt → (체인 전진) → PeriodHistoryOrg / SheetClosedPeriodArt / 헤더2. 앵커 재배선 지도 — billingAnchor.js
src/composables/billingAnchor.js:
js
export const currentAsOf = (line = "service-charge", edition = "actual") =>
periodOf(line, edition).endDate;
export const currentLabel = (line = "service-charge", edition = "actual") =>
periodLabelOf(line, edition);
export const currentDueDate = (line = "service-charge", edition = "actual") =>
periodOf(line, edition).dueDate;기본 인자 없이 호출하면 항상 "지금 체인이 가리키는 open 엔트리" 기준이다. 마감이 일어나면 다음 호출부터 자동으로 새 값을 반환한다(모듈 상수가 아니라 함수 호출이므로 매번 재평가).
2-1. 컴포저블 8파일 재배선(정적 상수 → 함수 호출)
| 파일 | 변경 |
|---|---|
billingNature.js | vatRateAt(date = REFERENCE_DATE) → vatRateAt(date = currentAsOf()), withVat(byComponent, date = REFERENCE_DATE) → currentAsOf() |
lateFeePolicy.js | rateScheduleAt(date = REFERENCE_DATE) 기본값 + 2곳의 명시 호출(rateScheduleAt(REFERENCE_DATE)) → currentAsOf() |
useCollectingActions.js | 초기 ref(REFERENCE_DATE) + 1곳 → currentAsOf() |
useCollectingAsOf.js | 초기값·클램프 로직(3곳) → currentAsOf() |
useInvoice.js | invoiceFor(selection, asOf = REFERENCE_DATE) → currentAsOf() |
useInvoicingDetail.js | withVat(byComp, REFERENCE_DATE), vatRateAt(REFERENCE_DATE) → currentAsOf() |
useForwarding.js | 모듈 상수 CURRENT_LABEL 제거, 4곳 currentLabel: CURRENT_LABEL → currentLabel: currentLabel()(빌더 내부 매 평가) |
useInitialReceivables.js | 당기 라벨 가드(addPeriod + importCsv 2곳) label === CURRENT_PERIOD → label === currentLabel() |
useBillingJournal.js | (2026-07-11 후속, A-1) 모듈 상수 COLLECTION_PERIOD_TAG = CURRENT_PERIOD.replace('-','') 제거 → vouchersCollection() 내부 const periodTag = currentLabel().replace('-', '')로 매 호출 재평가(SUS-/RECLASS-/RCP- billingRef 접두 3곳) |
변경하지 않은 것(의도적): billingNature.js의 VAT_RATE = vatRateAt()(모듈 로드 시 1회 평가 상수), lateFeePolicy.js의 EDIT_DEFAULT_EFFECTIVE_FROM(모듈 상수). REFERENCE_DATE/CURRENT_PERIOD 자체의 export(billingNature.js)는 시드 생성용으로 그대로 남아있다.
정정(2026-07-11, A-1 해소): 이 표는 원래
useBillingJournal.js의COLLECTION_PERIOD_TAG를 "값 불변 태그로 설계"돼 의도적으로 미변경이라고 서술했었다 — 이는 잘못된 판단이었다(opus 최종 리뷰가 적발: 마감 후에도 수납 전표 billingRef가 옛 차수 태그에 고정되는 실결함). 위 표에 새 행으로 반영해 정정했다 —COLLECTION_PERIOD_TAG는 더 이상 존재하지 않고periodTag(로컬 변수, 매 호출 재평가)로 대체됐다.
2-2. .vue 컴포넌트 4파일 재배선(런타임 asOf 직접 소비)
컴포저블 8파일 재배선(위 §2-1) 이후 별도로 처리된 갭 — REFERENCE_DATE를 화면에서 직접 import해 수납 처리 시점(receivedAt)에 쓰던 4곳:
| 파일 | 위치 | 용도 |
|---|---|---|
SheetInvoicingDetailArt.vue:20 | collectingDetailFor(..., currentAsOf()) | 청구 상세 조회 시점 |
AdvanceBalanceOrg.vue:19(collecting/detail) | refundAdvance(..., currentAsOf()) | 선수금 환급 처리일 |
DynamicTableOrg.vue:407,479(collecting/contract) | recordReceipt({..., receivedAt: currentAsOf()}) | 단건/일괄 수납 처리일 |
advance-receipt/MainOrg.vue:30 | refundAdvance(..., currentAsOf()) | 선수금 환급 처리일 |
이 4곳을 놓치면 마감 후에도 수납/환급 화면이 여전히 옛 차수 날짜로 기록을 남긴다(체인은 전진했는데 UI가 과거 시점 앵커를 계속 참조) — 신규 화면을 추가할 때 REFERENCE_DATE를 직접 import하고 있다면 항상 billingAnchor.currentAsOf()로 교체할 것.
2-3. 순환 import 안전성
billingAnchor.js → useBillingPeriod.js → useBillingEdition.js → billingLines.js(외부 의존 없음). 8개 소비 파일 중 어느 것도 이 체인에 포함되지 않아 무순환. useBillingEdition.js가 (T5에서) useBillingPeriod의 periodOf를 헤더 라벨 파생용으로 새로 import했는데, 양쪽 다 함수 본문 내부에서만 참조(모듈 최상위 즉시실행 없음)라 ESM 순환이 안전 — npm run build green으로 실측 확인됨.
3. usePeriodClose 계약
js
import { usePeriodClose } from "@/composables/usePeriodClose";
const { closeGate, closePeriod } = usePeriodClose(); // line/edition 기본값 'service-charge'/'actual'3-1. closeGate() — 순수 조회(부작용 없음)
js
{
canClose: boolean,
blockers: string[], // 예: ["가수금 2건(13,000원) 정리 필요 — 식별·재분류 후 마감하세요"]
summary: {
이월계약수, 이월총액, 선수금잔액, 가수금건수, 당기부과총액, 당기수납총액
}
}computed(() => closeGate())로 감싸 UI에서 반응형 소비(예:DialogClosePeriodArt.vue,ActionBarOrg.vue가 각자 독립 호출 — 상태 공유 없음, 순수 함수라 매번 재계산해도 저렴).- 부작용 없음 — 몇 번을 호출해도 데이터가 바뀌지 않는다. 다이얼로그가 열려있는 동안 실시간으로 최신 게이트 상태를 반영하고 싶다면 그냥 다시 호출하면 된다.
3-2. closePeriod() — 실행(부작용 있음, 1회성)
js
const res = closePeriod();
// res.ok === false → { ok: false, reason: string } (closeGate 재확인 실패, blockers.join(' / '))
// res.ok === true → { ok: true, closedLabel: '2025-12', carried: 18 }- 내부적으로
closeGate()를 다시 호출해canClose를 재확인한다(다이얼로그가 열려있는 동안 데이터가 바뀌었을 가능성 방어) — UI에서 이중 가드 불필요, 그냥 호출만 하면 된다. - 성공 시 §"3 마감 순서도"(BE 문서 §3)의 7단계가 전부 동기적으로 완료된 뒤 반환된다 — 호출 직후
useBillingPeriod().period/billingAnchor.currentLabel()등을 읽으면 이미 새 차수 값이다(별도 대기·polling 불필요, Vue reactivity가 자동 갱신).
4. UI 배선
4-1. ActionBarOrg.vue (billing/_core/console/receivable-aging/blocks/)
미수금 연령분석 콘솔의 탭바 아래 배치. [이월 확정 · 차수 마감] 버튼.
vue
const canOpenGate = computed(() => statusOf('collecting') === 'confirmed')- 게이트:
useConsoleStage().statusOf('collecting') === 'confirmed'(수납 확정) — 마감 실행 자체의 가부(가수금 등)는 이 버튼 게이트가 아니라 다이얼로그 내부(closeGate().canClose)가 담당한다. 즉 2단 게이트: ①버튼 활성화(수납 확정 여부) ②다이얼로그 내 실행 버튼 활성화(가수금 등 blockers 여부). isCanon = line==='service-charge' && edition==='actual'일 때만 렌더 — 다른 라인/잠정정산에서는 버튼 자체가 없다(v-if="isCanon").command="show-modal" commandfor="dialog-close-period-art"— 네이티브<dialog>invoker 패턴(popovertarget 계열과 동형,command속성 사용).
4-2. DialogClosePeriodArt.vue (같은 디렉터리 overlays/)
- 요약 테이블(
table-static) + blocker/경고alert컴포넌트 전환(v-if="!gate.canClose"→alert-error-subtle role="alert"/v-else→alert-warning-subtle role="note"). onConfirm()이closePeriod()를 호출하고res.ok면 다이얼로그를close()— 실패 시(이론상 도달 안 함, 버튼이:disabled="!gate.canClose") 조용히 무시.- 마크업 관용은
service-charge/setting/item/general/overlays/DialogDeleteChargesArt.vue(확인 다이얼로그 정본)을 그대로 따름, alert 세부 구조는DialogCollectingCancelArt.vue(alert-icon+alert-content>alert-title+alert-description) 관용에 정합.
4-3. PeriodHistoryOrg.vue + SheetClosedPeriodArt.vue (service-charge/actual/console/general/)
PeriodHistoryOrg.vue(blocks/): 콘솔개요 [기본정보] 카드 바로 아래(general/MainOrg.vue에서<DynamicTableOrg />다음에 배치).useBillingPeriod().periods(전체 엔트리,slice().reverse()로 최신 우선)를 테이블로 나열.status==='closed'인 행만 이름이 클릭 가능한 버튼(font-medium cursor-pointer hover:underline— §10 클릭 affordance 컨벤션)이고,open행은 평문.SheetClosedPeriodArt.vue(overlays/): 유형 B(단일 콘텐츠, 카드 래핑 없음,sheet-width-3xl) — read-only, 편집 버튼 없음, sheet-footer 자체가 없다(SheetUnitsArt.vue선례를 따름 — CLAUDE.md §0-8 "Read↔Update 듀얼 시트 금지" 원칙과 별개로, 이 시트는 애초에 편집 대상이 아니므로 footer 자체를 생략).- 선택 상태 공유:
overlays/selectedClosedPeriod.js— 단일 모듈 레벨ref(멤버 시트의selectedBilling관용과 동형).PeriodHistoryOrg가 클릭 시 엔트리를 이 ref에 담고document.getElementById('sheet-closed-period-art')?.showModal()호출.
4-4. 헤더 periodLabel 동적화 — useBillingEdition.js
js
// billing computed 내부
if (line === "service-charge" && edition) {
const p = periodOf(line, edition);
return { ...route.meta.billing, periodLabel: `관리비 ${p.year}년 ${Number(p.month)}월` };
}
return route.meta.billing; // 그 외 라인/미에디션은 정적값 그대로- 최소 침습 —
service-charge라인 + 에디션 존재 조건에서만 오버라이드, 다른 라인은 여전히billingLines.js정적 config. DEFAULT_PERIOD(2025-12) 파생값이 기존 정적 문자열'관리비 2025년 12월'과 byte-identical이라 첫 렌더 회귀 0.
5. billingRepo 하이드레이션 패턴 — 동기 부트스트랩 + 비동기 오버레이
js
// useBillingPeriod.js
let activeRepo = mockRepo
const state = ref(sanitizeState(mockRepo.loadAllSync())) // ① 동기 초기 하이드레이트
;(async () => {
const { repo, backend } = await resolveBillingRepo() // ② 비동기 백엔드 판정
activeRepo = repo
if (backend === 'supabase') {
const remote = await repo.loadAll()
if (remote) state.value = sanitizeState(remote) // ③ 원격이 정본이면 교체
}
})()
watch(state, (v) => {
mockRepo.saveChain('*', null, v) // 로컬은 항상 저장(오프라인 폴백)
if (activeRepo !== mockRepo) {
for (const key of Object.keys(v)) activeRepo.saveChain(key, v[key]).catch(...)
}
}, { deep: true })왜 동기+비동기 하이브리드인가: 브리프 원안 의사코드는 초기 하이드레이트도 await mockRepo.loadAll()로 전부 비동기 처리했다. 그대로 구현하면 모듈 평가 직후(마이크로태스크 1틱 이전) 동기적으로 periodOf()를 호출하는 기존 소비처·테스트가 빈 state를 보게 된다(useBillingPeriod.spec.js의 다수 케이스가 vi.resetModules() → await import(...) 직후 즉시 동기 mod.periodOf(...)를 호출해 영속값을 기대). localStorage.getItem은 원래 동기 API라 마이크로태스크를 거칠 이유가 없으므로, mockRepo.loadAllSync()(공개 async 계약 loadAll과 별개의 내부 전용 동기 변형)를 추가해 초기값만 동기 하이드레이트하고, Supabase 원격 하이드레이트만 비동기 오버레이로 남겼다.
신규 seam을 추가할 때 이 패턴을 재사용하려면: 공개 계약(loadAll/saveChain)은 async로 통일하되, 모듈 부트 시점에 동기 접근이 필요한 소비처가 있다면(대부분의 컴포저블 싱글톤이 그렇다) loadXxxSync() 내부 전용 변형을 추가하는 것을 허용한다 — documentCenterRepo.js(HR 트랙 선례)는 순수 async였는데, 이 차이는 소비처가 모듈 로드 직후 동기 접근을 요구하는지 여부에 달려 있다.
6. 테스트 위치
| 파일 | 대상 |
|---|---|
src/composables/__tests__/useBillingPeriod.spec.js | 체인 구조(entries/currentIndex)·closeCurrentPeriod 원자 전이·periodsOf/periodOf·구형→신형 마이그레이션·localStorage 라운드트립. 기존 14 + 신규 5. |
src/composables/__tests__/billingAnchor.spec.js | 시드 상태 동치 오라클(currentAsOf()===REFERENCE_DATE, currentLabel()===CURRENT_PERIOD, currentDueDate()===DEFAULT_PERIOD.dueDate). |
src/composables/__tests__/useForwarding.spec.js | vatIncluded 이중 VAT 방지(§ BE 문서 §4) — red-on-revert로 검증된 유일한 방어선. |
src/composables/__tests__/useInitialReceivables.spec.js | appendCarriedPeriod — 당기 라벨 가드 없음, 신규 계약 identity 등재. |
src/composables/__tests__/useMeterReadings.spec.js | rollForward() — 당월→전월→전전월 shift, 당월 지침 유지(사용량 0 재시작). |
src/composables/__tests__/usePeriodClose.spec.js | closeGate()/closePeriod() 오케스트레이션 시나리오(brief §Step1 그대로) — 가수금 blocker·정상 마감 흐름. |
src/composables/__tests__/billingRepo.spec.js | mockRepo 라운드트립 + makeSupabaseRepo 계약 스텁(select/upsert 형태). |
src/components/billing/_core/console/receivable-aging/__tests__/ClosePeriodWiring.spec.js | ActionBarOrg disabled 가드 + DialogClosePeriodArt 렌더 배선. 실행 로직 자체는 usePeriodClose.spec.js가 커버(이 스펙은 버튼 존재/게이트만 확인). |
src/components/service-charge/actual/console/general/__tests__/PeriodWiring.spec.js | 기존 스토어 표시+편집 배선(체인 진화 이전부터 존재, period/periods 양쪽 여전히 유효). |
PeriodHistoryOrg/SheetClosedPeriodArt는 브리프 테스트 목록에 없어 전용 스펙 미작성 — 신규 화면이라 후속 태스크에서 필요 시 추가 검토(다이얼로그 showModal/close는 jsdom 미지원이라 시트/다이얼로그 자체를 여는 브라우저 API 왕복은 어차피 컴포넌트 테스트 커버리지 밖).
7. 확장 포인트 (follow-up)
- 수납 전표 billingRef 태그 정적 잔존 — ✅ 2026-07-11 해소(A-1):
useBillingJournal.js의COLLECTION_PERIOD_TAG모듈 상수를 제거하고vouchersCollection()내부currentLabel()매 호출 재평가(periodTag)로 교체했다(§2-1 표 참고). 마감 후 신규 수납 전표는 다음 차수 태그를 단다. 과거 전표 소급 갱신은 없음(마감이receipts를 리셋하므로 해당 시나리오 자체가 발생하지 않음). - 재이월 시
dueDay완전 보존 — ✅ 2026-07-11 해소(A-2):useForwarding.buildRow가 행에dueDay: p.dueDay를 노출하고usePeriodClose.collectCarry()가 이를 스냅샷에 실어appendCarriedPeriod(..., { dueDay, ... }, ...)로 전달하도록 배선했다 — 계약별 차수dueDay원자값이 이제 마감을 거쳐도 유실되지 않는다(BE 문서 §7). 다만buildNextEntry의dueDay문자열 슬라이스 파싱(|| 15폴백) 자체는 그대로 남아있다 — 이번 해소는 "값이 어디에도 실리지 않아 사라지던" 유실 문제이고, 파싱 강건성(Date 객체 기반 전환)은 별도 후속 과제로 유효하다. managementCode채번 로직 — ✅ 2026-07-11 해소(A-3): 끝자리 한 자리만 취하던 파싱(replace(/\D/g,'').slice(-1))을match(/\d+$/)(마지막 숫자 그룹 전체)로 교체 — 두 자릿수 이상에서 퇴행하던 결함이 사라졌다(BE 문서 §7). 여전히 관리코드보다year+month+edition조합이 실질 키이므로 영향은 표시값 한정.- Supabase 실접속: seam은 완결돼 있고 코드 변경 없이 전환 가능 — 오너가
VITE_SUPABASE_URL을 공급하고 DDL을 1회 실행하면 즉시 다중 세션 영속으로 전환된다(BE 문서 §9). 테스트 러너는 vitest 가드로 항상 mock 강제(2026-07-11 후속, BE 문서 §9) — URL이 채워져도MODE==='test'에서는 실 Supabase에 기록하지 않는다. PeriodHistoryOrg/SheetClosedPeriodArt전용 테스트: 현재 미작성 — 신규 화면 회귀 방지를 위해 추가 검토.- provisional 라인의 마감: 현재
ActionBarOrg는isCanon(service-charge:actual) 조건으로만 렌더된다. 잠정정산에 마감 개념을 도입할지는 오너 결정 필요(현재는 YAGNI — 잠정은 마감 전 임시 스냅샷이라는 전제와 상충).
8. 관련 문서
docs/handoff/backend/billing-period-chain.md— 상태머신·마감 순서도·vatIncluded seam·불변식·DDL(본 문서와 동반 작성).docs/handoff/frontend/billing-console-period.md— 이 트랙 이전 스토어 소비 지도(단일 엔트리 시절). 문서 상단 진화 노트 참고.docs/manual/billing-period-close.md— 경리 사용자용 마감 절차.docs/manual/receivable-aging.md— 마감 버튼이 위치한 화면의 기존 사용자 매뉴얼.