Skip to content

기초데이터 — 개시 미수 마스터 (BE 참고 매뉴얼)

관리비 단독 상용화 프로그램 #3 (P0) — 2026-07-01 완료. 정본 설계 spec: docs/superpowers/specs/2026-07-01-initial-data-onboarding-design.md 연관 FE 핸드오프: docs/handoff/frontend/service-charge-initial-data.md⚠ 2026-07-06 W3b 마스터 단일화 갱신 — Fable 1차 감사 FB-13(축 미러 stale)·FB-4(오버레이 비대칭)·FB-19(당기 라벨 이중차감) 해소. .unit/.member는 더 이상 별도 저장 미러가 아니라 .contract + priorIdentity(정적 사실맵)에서 매번 파생한다. 설계: docs/superpowers/specs/2026-07-06-initial-receivables-master-unify-design.md. 아래 §2-2·§5·§7·§8이 신 as-built로 갱신됨(구 서술은 취소선/삭제).


1. 기능 개요

신규 단지 온보딩 시 이전부터 존재하던 미납 관리비(개시 미수)를 세대별·차수별로 입력한다. 입력된 데이터는 캐논 사슬을 통해 이월/미수 파생 → 연체료 → 미수금 연령분석 → 고지서 전기이월 → 개시 분개(E1)까지 자동 전파된다.

현재 구현은 클라이언트 사이드 모의 데이터(reactive singleton)다. 실 서비스에서는 이 단원이 기술하는 데이터 모델과 API 계약을 백엔드가 제공해야 한다.


2. 데이터 모델

2-1. 마스터 그레인

진실원 단위 = (계약, 차수).

데이터 모델 표기: priorPeriods는 Vue Ref<{ contract, unit, member }> 객체이므로, 실제 코드에서 계약 맵에 접근할 때는 priorPeriods.value.contract를 사용한다. 아래 의사코드에서는 가독성을 위해 .value를 생략한다.

priorPeriods.value.contract = {
  '<contractCode>': [
    {
      label: 'YYYY-MM',          // 차수 (필수)
      dueDay: 5,                 // 납기일(일자, 선택 — 미지정 시 DEFAULT_DUE_DAY=5)
      billedByComponent: {
        과세원금: 4000,          // 공급가액 기준, excl. VAT (없으면 0 — 제로 기본값)
        면세원금: 6000,          // 공급가액 기준 (없으면 0 — 제로 기본값)
        영세원금: 0              // (없으면 0 — 제로 기본값)
      }
    },
    ...
  ],
  ...
}

billedByComponent 키 필수/선택 정정: 세 키(과세원금·면세원금·영세원금) 모두 의미상 선택적-제로-기본값이다. importCsv는 항상 세 키를 0으로 초기화하며, 데모 시드(demoSeed)는 영세원금 키를 생략할 수 있다. "필수"/"선택" 구분보다 정확한 표현은 "누락 시 0으로 처리"이다. 어떤 키도 하드 필수값을 요구하지 않는다.

핵심 원칙:

  • 저장 값 = 공급가액(excl. VAT). 공급대가(VAT 포함)는 withVat() 함수가 파생한다.
  • 연체료는 저장하지 않는다 — dueDay + 차수 경과 일수 + 단계이율 정책(lateFeePolicy)이 파생한다.
  • label = YYYY-MM 정규식 /^\d{4}-(0[1-9]|1[0-2])$/ 검증.

2-2. 3축 투영 (2026-07-06 W3b 갱신 — 파생 아키텍처)

priorPeriods{ contract, unit, member } 3-맵을 노출하지만, 저장소는 contract 하나뿐이다:

  • .contract = 유일 진실원(contractPeriods ref). CRUD(addPeriod/updatePeriod/removePeriod/upsertContract/removeContract) 전부 이 맵만 변경한다.
  • .unit/.member = 매 접근 시 computed 파생(unitPeriods/memberPeriods in useInitialReceivables.js). 저장소가 아니므로 stale이 구조적으로 불가능하다(FB-13 근본 해소).
    • 파생 경로: 정적 priorIdentity(contract→{unitCode, memberCode}, 시드에서 1회 도출·CRUD로 불변)로 .contract의 각 계약을 unitCode/memberCode 그룹으로 묶고, 같은 축에 매핑된 여러 계약의 차수를 label 기준 병합(mergePriorArrays — billedByComponent 성분별 합산, dueDay는 최초 정의값 유지)한다.
    • 데모는 1계약=1유닛=1멤버(1:1)이라 사실상 통과합산이지만, 다계약→1유닛(세대분할·합가) 케이스도 일반화되어 옳게 동작한다.
    • priorIdentity에 없는 계약(신규 생성된 테스트 계약 등)은 축 파생 대상에서 제외 — .contract만 진실원 유지.
  • 신규 입력(계약 기준)은 여전히 contract 맵에만 쓴다. .unit/.member·당기 부과 있는 계약의 forwarding 집계·useForwarding의 prior-only 폴백 — 전부 자동 반영(파생 체이닝, 수동 미러 갱신 불요).
  • prior-only 세대 신규 생성(새 unitCode/memberCode를 갖는 계약을 처음부터 만드는 것)은 여전히 현 스코프 밖 — 단, priorIdentity에 등재된 기존 prior-only 계약(c-end 등) 편집은 축에 정상 반영된다(이 부분이 W3b 이전엔 안 됐음 — FB-13 증상).

삭제된 구서술(회귀 방지 기록): "시드 초기화 시 전체 3-맵을 deep clone으로 적재" — 더는 사실이 아니다. deep clone은 .contract에만 적용되고(seedContractPeriods), .unit/.member는 위 파생 경로로만 존재한다.


3. 캐논 사슬

priorPeriods (개시 미수 마스터 — useInitialReceivables)
  → useForwarding (이월/미수 차수별 FIFO 충당, 3축)
      → useReceivableAging (미수금 연령분석 — 차수 버킷)
      → interestEngine / lateFeePolicy (연체료 차수별 단계이율)
      → InvoiceDynamicOrg (고지서 전기이월 표시)
      → useBillingJournal.vouchersOpeningReceivable (E1 개시분개)

useForwardingpriorPeriods를 reactive getter로 소비하므로 마스터 변경 → forwarding computed 재계산 → 하위 전부 자동 전파.


4. E1 개시 분개 규칙

개시 미수는 개시 시점 회계 분개(E1)를 생성한다:

구분계정과목코드비고
DR미수금0108공급대가(VAT 포함)
CR이월이익잉여금0375과세·면세·영세 원금 합계
CR부가세예수금0255과세원금 × 10%
  • 과세·면세·영세 구분 각각 별도 라인으로 분개.
  • 연체료는 E1에 포함하지 않는다(별도 발생 분개).
  • 구현: useBillingJournal.jsvouchersOpeningReceivable().

5. 불변식

불변식내용
시드 회귀0모듈 초기화 시 buildSeed(DEMO_CONFIG).priorPeriods.contract deep clone(.unit/.member는 파생이라 clone 대상 아님). 시드 원본 객체 불변 보장.
파생==오라클 회귀0 (W3b)시드 상태에서 파생 unitPeriods/memberPeriods 값이 buildSeed().priorPeriods의 구 .unit/.member 리터럴(현재는 회귀0 대조 오라클로만 유지, 편집 소스 아님)과 값 동일이어야 한다. 다르면 파생 로직(priorIdentity 매핑 or mergePriorArrays) 결함 — STOP.
축 미러 stale 불가 (FB-13, W3b).unit/.member가 별도 저장소가 아니므로, .contract 편집(addPeriod/updatePeriod/removePeriod/removeContract) 즉시 두 축에 반영된다. "계약축만 갱신되고 유닛/멤버축은 그대로"인 상태는 구조적으로 발생할 수 없다.
당기 라벨 거부 (FB-19, W3b)addPeriod/importCsvlabel === CURRENT_PERIOD이면 거부({ ok:false, reason } or CSV 오류행) — 개시미수 마스터는 이월(prior) 전용, 당기는 캐논(useAllocations)에서 파생되므로 같은 label로 이중 존재하면 useForwarding paidMap 키 공유로 수납이 이중차감된다.
오버레이 purge 의무 (FB-4, W3b)소비처(SheetCuOpeningArt 저장 흐름)가 removePeriod(cc, label) 호출 시 반드시 useForwarding.purgeOverlay('contract', cc, label)을 함께 호출한다. 안 하면 고아 collected/adjusted가 남아 동일 label 재등록 시 유령 수납으로 부활한다. useInitialReceivablesuseForwarding을 import하지 않으므로(순환 금지) 마스터 쪽이 자동으로 purge하지 못한다 — 이 계약은 코드 리뷰로 강제해야 한다.
초과수납 무음 증발 금지 (FB-4, W3b)updatePeriod로 billed를 기수납(paid+adj) 미만으로 낮추면 outstanding은 여전히 max(0,...) 클램프(음수 미수 없음)이나, useForwarding.overpaidWarnings()로 초과분을 조회 가능해야 한다. periodStatus의 '완납' 어휘 자체는 변경하지 않았다(status 확대는 blast radius가 커 후속 — 이번 웨이브는 경고 노출까지).
기존 테스트 전통과useForwarding.spec.js · forwardingCarryoverDisplay.spec.js · receivableAging.spec.js · useFinancialStatements.spec.js(E1) 전부 시드 직후 출력과 동일 — 회귀 0건 기대.
3-맵 구조 보존priorPeriods.value{ contract, unit, member } 최상위 키 구조를 삭제하거나 재구성하지 않는다(내부 구현이 저장/파생으로 나뉘어도 표면 표기는 무변경).

6. CSV 계약 (import API)

6-1. 헤더

계약코드,입주민,차수,납기일,과세원금,면세원금,영세원금
  • 입주민 열은 파싱 시 무시(참고용).
  • 첫 줄에 계약코드가 포함되면 헤더로 간주하고 스킵.

6-2. 행 검증 (per-row)

조건에러 사유
계약코드 열이 비어 있음'계약코드 누락'
차수 열이 YYYY-MM 형식 불일치'차수 형식 오류(YYYY-MM)'
  • 검증 실패 행은 건너뛰고 errors 배열에 { line, reason } 추가.
  • line = 1-indexed(헤더 포함). 헤더가 1번이면 첫 데이터 행은 2번.
  • 부분 성공 허용 — 성공한 행은 즉시 addPeriod 처리.

6-3. 숫자 파싱

  • 따옴표 인지(quote-aware) CSV: "4,000" → 4000.
  • 빈값·파싱 실패 → 0으로 처리(에러 없음).

6-4. upsert 동작

같은 (계약코드, 차수) 조합이 이미 있으면 덮어씌운다. 신규이면 추가.


7. API 요약 (composable — 실 BE 계약 참조용)

메서드시그니처설명
addPeriod(contractCode, { label, dueDay?, billedByComponent })차수 추가(같은 label이면 upsert)
updatePeriod(contractCode, label, patch)특정 차수 partial update
removePeriod(contractCode, label)차수 삭제
upsertContract({ contractCode })계약(세대) 진입 보장 — 차수 배열 없으면 빈 배열 생성
removeContract(contractCode)계약 전체 삭제
importCsv(text) → { added, errors: [{ line, reason }] }CSV 텍스트 일괄 파싱·import. label===CURRENT_PERIOD 행은 errors로 분리(W3b).
resetToSeed()시드 상태로 초기화(테스트·데모)

cross-composable (useForwarding — W3b 신설, useInitialReceivables가 아니라 useForwarding이 소유):

메서드시그니처설명
purgeOverlay(axis, key, label)해당 축/키/label의 collected/adjusted 오버레이 삭제. removePeriod반드시 동반 호출(FB-4).
overpaidWarnings() → [{ contractCode, name, label, billed, settled, overpaid }]contract 축을 스캔해 settled(=collected+adjusted) > billed인 차수를 경고로 노출.

8. 엣지 케이스

케이스현재 처리
prior-only 세대(부과종료·일시중단) — PRIOR_PERIODS.unit/.member 폴백시드가 보유한 prior-only 세대(c-end, c-gap 등)는 unit/member 파생(W3b — priorIdentity 매핑, 더 이상 고정 미러 아님)으로 forwarding 처리. 이 계약을 편집(addPeriod/updatePeriod/removePeriod)하면 파생이 즉시 반영된다(FB-13 이전엔 stale이었음). 화면에서 축 정체성이 없는 신규 계약을 처음부터 생성하는 케이스는 여전히 스코프 밖(#3 미포함).
선수금·가수금 개시 잔액현 스코프 밖 — 미수만 처리. 별도 후속 작업.
연체료 컬럼현재 테이블에 미표시(interestEngine 통합 후 추가 예정).
동일 계약코드 중복 importupsert — 후입 값으로 덮어씌움.
납기일 미지정DEFAULT_DUE_DAY = 5 적용.
차수 삭제 후 동일 label 재등록 (W3b)removePeriod 단독 호출은 오버레이(collected/adjusted)를 고아로 남긴다 — 소비처가 purgeOverlay 동반 호출해야 유령 수납 부활을 막는다(§5 "오버레이 purge 의무").
billed 하향이 기수납보다 작음 (W3b)outstandingmax(0,...) 클램프되어 음수가 되지 않지만, overpaidWarnings()로 조회 가능한 경고를 남긴다. periodStatus 어휘(예: '완납') 자체는 정정하지 않는다 — 표시층(SheetCuOpeningArt)의 경고 배지로만 가시화.
당기(CURRENT_PERIOD) label 등록 시도 (W3b)addPeriod 거부({ ok:false, reason }importCsv는 해당 행을 errors로 분리. 개시미수 마스터는 이월 전용.

9. 테스트 위치

파일커버 범위
src/composables/__tests__/useInitialReceivables.spec.js시드 초기화 동등성, CRUD, importCsv 파싱, forwarding 재배선 동등성, 입력→전파 검증. W3b 추가: 당기 label 거부(addPeriod/importCsv)
src/composables/__tests__/useForwarding.spec.jsforwarding 재배선 후 출력 동등성. W3b 추가: addPeriod stale 미반영→반영 확인(FB-13 RED→GREEN), removeContract 3축 소멸, 파생==contract 직접대조, purgeOverlay 유령수납 부활 없음, overpaidWarnings 노출
src/components/service-charge/setting/initial-data/general/overlays/__tests__/SheetCuOpeningArt.spec.jsW3b 신규: 정상 상태 무경고 / billed 하향 시 초과수납 배지+툴팁 노출(mount)
src/composables/__tests__/receivableAging.spec.js미수금 연령분석 downstream 회귀
src/composables/__tests__/useFinancialStatements.spec.jsE1 개시분개 회귀