Skip to content

관리비 기본설정 저장 + 콘솔 개요 KPI — BE 참고

관리비 상용화 잔여 프로그램(설정 SO). 설계 정본: docs/superpowers/specs/2026-07-08-billing-settings-overview-design.md.


1. 개요

관리비 단독 상용출시 프로그램의 마지막 두 잔여 항목을 실화했다.

  1. 기본설정 저장: 기본설정(일반 탭)의 부가세 산출기준·끝수처리·중간정산 개월수가 이전에는 read-only mock + dead save(시트에 로컬 ref만 있고 커밋 미배선)였다. 이제 useBillingGeneralSettings canon 컴포저블 + localStorage 영속 + 3개 시트 commit 배선으로 실제로 저장·유지된다.
  2. 콘솔 개요 KPI: 하드코딩 카운트(999)를 제거하고 기존 캐논 컴포저블을 조합한 read-only 집계자 useBillingOverview를 추가했다. 집계 계약은 6개 값을 유지하지만 2026-07-26부터 개요 첫 화면은 같은 정산회차 기준의 핵심 4지표만 노출한다.

2026-07-26의 상단 탭 활성 위치 보정과 개요의 중복 단계 CTA 제거는 표시·내비게이션 정리다. API·상태 전이·DB 계약을 추가하거나 변경하지 않는다. 같은 날 @leysys/eds 1.2.3에서 탭 plane 배경과 수평 스크롤 viewport의 소유자를 TabsList로 일치시킨 후속 정정도 CSS/DOM 표시 계층 변경이며 서버 요청 계약에는 영향이 없다. 제품은 EDS 1.2.2 회귀를 보정하던 w-full min-w-0 overflow-x-auto를 제거했다. 2026-07-28부터 scrollbar 표시 정책은 exact 두께 class를 붙이지 않은 전역 기본 Native를 사용한다.


2. useBillingGeneralSettings — 기본설정 canon

src/composables/useBillingGeneralSettings.js

2-1. 모델

모듈 레벨 reactive 싱글톤(lateFeePolicy·useBillingPreset 관용 — clone-replace·DEFAULT 불변).

js
settings.value = {
  vatCalcBasis: '공급가합산후' | '항목별',       // 표시용 — 계산 로직 미분기(§2-4)
  roundingSupply: { unit: 1|10|100, mode: '반올림'|'올림'|'내림' },
  roundingVat:    { unit: 1|10|100, mode: '반올림'|'올림'|'내림' },
  interimSettlementMonths: 1~12                   // 정수
}
  • DEFAULT_GENERAL_SETTINGSObject.freeze로 보호된 공유 싱글톤 — 절대 변이 금지. 모든 setter는 settings.value = { ...settings.value, ... } clone-replace만 수행한다.
  • sanitize(parsed): localStorage에서 읽은 값의 필드별 유효성 검사 — 무효 필드는 개별적으로 DEFAULT 폴백(부분 손상 방어).

2-2. localStorage 영속

  • 키: leyve.billing.generalSettings (useBillingPreset 선례와 동일 네임스페이스 leyve.billing.*).
  • watch(settings, ..., { deep: true })가 변경마다 JSON.stringify(v)로 저장.
  • 모듈 로드 시 loadInitial()이 저장값을 sanitize() 거쳐 초기화(저장값 없거나 파싱 실패 시 cloneDefault()).
  • 테스트 환경 격리: typeof localStorage !== 'undefined' 가드(hasStorage) — SSR/비-DOM 환경에서도 안전.

2-3. API

함수설명실패 시
setVatCalcBasis(basis)VAT_CALC_BASES 중 하나로 교체{ ok:false, reason } (변이 없음)
setRounding({ scope:'supply'|'vat', unit, mode })부분 갱신(둘 중 하나만 넘겨도 나머지는 현재값 유지)무효 scope 시 { ok:false }
setInterimSettlementMonths(n)정수 1~12만 허용범위 밖이면 { ok:false }, settings 무변
applyRounding(value, { unit, mode })Vue 무관·순수함수, export됨 — VAT 계산 소비처가 직접 import

2-4. 불변식 — default 동치 (회귀0 게이트)

applyRounding(value, { unit: 1, mode: '반올림' }) === Math.round(value)   // 항상 성립

증명: unit=1일 때 scaled = value / 1 === value(부동소수 오차 없는 항등 연산) → Math.round(scaled)× 1Math.round(value)와 비트 동일. 이 불변식이 깨지면 기존 VAT/GL 대사 vitest 전체가 실패한다 — 감사 강화 코드 접촉 시 최우선 검증 대상.

vatCalcBasis표시 전용이다 — withVat/useAllocations/useBillingJournal/useInvoicingDetail의 실제 VAT 계산 분기는 이 값을 읽지 않는다(모두 성격별 원자 합산 후 applyRounding 단일 경로). BE 전환 시 "항목별 산출"을 실 계산 분기로 승격하려면 별도 설계가 필요하다(현재는 YAGNI로 스코프 아웃).


3. 끝수처리(roundingVat) → VAT 라운딩 일괄 적용

roundingVat가 관리비 청구 전 VAT 경로 4곳에 동일하게 소비된다 — 단일 경로, 발산 없음:

소비처파일라인
청구 VAT 파생(공급대가)src/composables/billingNature.js withVat()applyRounding(supply * vatRateAt(date), roundingVat.value)
세금계산서(과세 라인 세액)src/composables/useAllocations.js pushDocs()cat === '과세' ? applyRounding(supply * vatRateAt(), roundingVat.value) : 0
GL 전표(청구 분개 VAT)src/composables/useBillingJournal.js vouchersInvoicing()/vouchersInvoicingByCharge()applyRounding((byComp.과세원금 || 0) * vatRateAt(POSTING_DATE), roundingVat.value)
부과항목 명세(청구상세 시트 라인별)src/composables/useInvoicingDetail.js itemsFor()charge.taxCategory === '과세' ? applyRounding(principal * rate, roundingVat.value) : 0

각 파일 상단에서 const { roundingVat } = useBillingGeneralSettings()로 동일 컴포저블을 구독한다(모듈 로드 시 1회 바인딩, reactive .value 참조로 설정 변경 즉시 반영).

3-1. 부과항목 명세의 plug(잔차 흡수) 라인

useInvoicingDetail.itemsFor()는 라인별 독립 applyRounding을 적용하는데, 반올림 비가산성(floor(a)+floor(b) ≤ floor(a+b), 항상 성립하지만 항상 같지는 않음 — unit이 클수록 벌어짐) 때문에 라인 합계가 시트 전체 authoritative VAT(detailFor.current.vat)와 어긋날 수 있다. 마지막 과세 라인이 diff(잔차)를 흡수한다(재무제표 각주 반올림차이 관행과 동일). default(unit=1·반올림)에서는 대부분 diff===0(회귀0), 드물게 발생하는 극소 diff는 roundingVat 도입과 무관한 라운딩 비가산성 자체의 선재 특성이다.

3-2. 회귀0 증명 방법

  • src/composables/__tests__/useBillingGeneralSettings.spec.js: setter·localStorage 왕복·DEFAULT 불변.
  • src/composables/__tests__/vatRoundingCrossConsistency.spec.js: default(1·반올림) 시 4개 소비처의 VAT 계산이 서로 및 기존 Math.round 결과와 일치하는 독립 크로스체크. roundingVat를 (10·내림) 등으로 바꿨을 때 4곳 모두 일관되게 반영되는지도 검증.
  • 기존 VAT/GL/세금계산서 vitest(스펙 다수)는 default 값에서 전부 무변 — 새 코드 접촉이 기존 assertion을 하나도 바꾸지 않는다.

4. 중간정산 개월수 → 신규 create draft 초기값

SheetCreateProvisionalSettlementArt.vue · provisionalSettlementDraft.js

js
const { interimSettlementMonths } = useBillingGeneralSettings();
const newDraft = () =>
  makeProvisionalSettlementDraft(null, {
    defaultLookbackMonths: interimSettlementMonths.value,
  });
  • 설정은 create sheet가 새 빈 draft를 만들거나 reset할 때만 읽는다. singleton settlement params와 watch/override flag는 폐기됐다.
  • 사용자가 생성한 row의 lookback_monthsservice_charge_provisional_settlements가 서버에 보존한다. 이후 기본설정 변경은 기존 row를 갱신하지 않는다.
  • useProvisionalSettlement.js는 explicit input만 받는 순수 계산기이며 설정 컴포저블을 import하지 않는다.

5. useBillingOverview — 콘솔 개요 집계자

src/composables/useBillingOverview.js신규 canon 아님, 기존 컴포저블(useAllocations·useReceipts·useReceivableAging·billingNature.withVat)을 조합한 read-only 집계 함수 모음.

5-1. overviewKpis(axis = 'unit')

필드산식소스
부과총액Σ unit(withVat(u.currentPeriodByComponent)) 전 유닛useAllocations().invoicingByUnit()
수납액agg.총회수useReceivableAging().agingSummary(axis) — 당기 수납 + 당기 선수금 충당
수납률agg.총회수 / agg.총당기발생 (분모 0이면 0)useReceivableAging().agingSummary(axis)
미수금agg.총차기이월상동
연체건수agg.연체행수상동
최장연체차수agg.최장연체차수상동

수납률 재정의 이력(T3b, 2026-07-08): 최초 구현은 수납액(누적) ÷ 부과총액(당기)였으나, 분자(누적)·분모(당기)의 기간이 불일치해 100%를 초과하거나 오도할 수 있었다. useReceivableAging.agingSummary가 이미 제공하는 당기 스코프 총회수/총당기발생으로 교체해 분자·분모 기간을 맞추고, useForwarding.collect()가 당기 충당을 p.outstanding(≤ billed)로 구조적으로 클램프하므로 수납률 ∈ [0, 1]이 항상 보장된다.

수납액 재정의(2026-07-26): 개요 첫 화면에서 누적 입금액을 별도 KPI로 보여주던 계약을 폐기했다. 이제 수납액도 수납률 분자와 같은 총회수를 사용한다. 총회수에는 당기 현금 수납과 당기 선수금 충당이 함께 포함되므로 부과액·미수잔액·수납률 네 지표가 모두 같은 정산회차 기준이다. 미식별 가수금과 아직 충당하지 않은 선수금은 포함하지 않는다.

axis 기본값 'unit'은 임의 선택이 아니다 — agingSummary는 unit/member/contract 세 피벗의 합계가 서로 동치(Σ 정합, billing-charge-allocation-canon 원자1 불변식)이므로 어느 축을 선택해도 총계는 동일하다. entityCounts()가 unit 축을 쓰는 것과 정합을 맞추기 위해 unit을 기본값으로 고정했다.

5-2. entityCounts()

필드산식
세대수useAllocations().units.value.length
세대원useAllocations().members.value.length
사업자멤버별 taxProfiles.length(있으면) 또는 businessRegistrationNumber 보유 시 1로 집계한 합
차량0 고정 — billing 캐논에 대응하는 차량 엔티티 소스가 없음(master-data/vehicle 모듈은 독립 mock이며 billing과 미연동, 조사로 확인됨). 999 placeholder를 정직한 0으로 대체 — 999 유지보다 낫다는 판단(어떤 숫자든 "실제 데이터 없음"을 명시).

5-3. 성능/정합 주의

  • overviewKpis().부과총액invoicingByUnit()을 전 유닛 순회하며 매 호출 재계산한다(캐시 없음). 데모 규모(수십~수백 유닛)에서는 문제없으나, 실 서비스 규모(수천 유닛)에서는 뷰 레벨 computed(현재 MainOrg.vue가 이미 이렇게 감쌈)로 재계산 빈도를 제한하는 정도로는 부족할 수 있어 — BE 전환 시 서버 사이드 집계(materialized view/배치)로 이관 검토.
  • 미수금(agg.총차기이월)은 useReceivableAging이 이미 소유한 집계값을 그대로 재사용한다(독립 재계산 아님) — 개요 화면과 연령분석 화면의 미수금 숫자가 항상 일치하는 것이 설계 의도.

6. BE 전환 시 고려사항

  • 영속화: 현재 localStorage 단일 브라우저 스코프. 서버 전환 시 조직/콘솔 단위 설정 테이블(generalSettings 1행, 콘솔 FK)로 이관 — sanitize() 로직은 서버 측 유효성 검사로 그대로 재사용 가능.
  • 끝수처리 적용 시점: 현재는 매 계산마다 실시간으로 roundingVat.value를 읽는다(reactive). 서버에서는 "설정 변경 이후 거래만 신 라운딩 적용"이라는 effective-dated 계약이 필요할 수 있다(참고: docs/handoff/backend/settings-scope.md의 effective-dated 클래스 — 현재 roundingVat는 frozen도 effective-dated도 아닌 즉시 전역 적용, 과거 전표 재계산 시 소급 반영됨에 유의. 실거래 소급 방지가 필요하면 후속 설계 필요).
  • KPI 집계: overviewKpis/entityCounts는 순수 함수 호출 시점 스냅샷이다. BE 전환 시 API 응답 형태(필드명 한글 그대로 유지 — NAMING §10 한글 enum/필드 정본 참고)만 유지하면 프론트 소비 코드는 무변경.

7. 스코프 외

  • provisional P2(예수금 GL·true-up 차액 분개) — 별도 회계 트랙.
  • stage 파이프라인의 actual 서버 상태 전환 실화는 별도 트랙이다. 개요 UI는 useConsoleStage()의 현재 상태를 읽어 첫 미확정 단계와 바로가기만 표시한다.
  • 차량 마스터 데이터 연동, 사업자 전용 마스터 화면.
  • 부가세 산출 기준(vatCalcBasis)의 "항목별 산출" 실 계산 분기(§2-4 참고 — 현재 표시 전용).