Skip to content

FE 메인테이너 — 3축 빌링 테이블·상세시트

독자: FE 메인테이너/AI. 코어 6화면(부과·청구·수납 × 유닛/멤버/계약)을 이어 개발하려면 알아야 할 것. 정본: docs/decisions/PATTERN-TENANCY-AXES-CONTRACT-UNIT-MEMBER-2026-06-22.md(§1~§9). BE: [[tenancy-axes-billing.md]]. 관련 FE: [[collecting-detail-sheet.md]].

1. 라우트·컴포넌트 위치

  • 수납/청구 = _core 공유: src/components/billing/_core/console/{collecting,invoicing}/{unit,member,contract}/{MainOrg,blocks/DynamicTableOrg}.vue.
  • 부과 = 라인별 경로(아직 _core 미통합): src/components/service-charge/actual/console/charging/allocation-{unit,member,contract}/blocks/DynamicTableOrg.vue. 라우트 …/console/charging/allocation-{unit|contract|member}(buildLineRoutes.js).
  • MainOrg = TabBar+ToolBar+DynamicTable+Pagination+ActionBar + 오버레이 시트 마운트.

2. 데이터 소스 (composables)

  • useAllocations — 배분 캐논. allocate(charge), contractColumns/unitColumns/memberColumns(행렬 헤더), contractAtoms(), unitOccupants(uc)/memberHoldings(mc)(펼침), invoicingBy{Contract,Unit,Member}.
  • useForwardingforwardingBy{Contract,Unit,Member}(차수·미수·연체), collect.
  • useCollectingDetaildetailFor(axis,key,asOf) = 통합 합산 상세(buildOne+mergePeriods). 수납·청구 상세 공통 소스.
  • useBillingEdition(isCanon = service-charge ∧ actual), useModuleSubscription(accountingEnabled 게이팅).

3. 리스트 테이블 패턴 (수납/청구)

  • 유닛/멤버: 행 = forwarding 행(canon buildCanonRows). 컬럼 = 유닛/멤버 + 대표상대 + 당월N + 시작/종료 + 총청구금 + 미수금(수납)/전송(청구). 펼침(expanded[id]) = unitOccupants/memberHoldings(점유자/보유유닛별 실배분, 시작/종료·금액). 펼침행 클릭 → 실 contractCode로 상세.
  • 계약: atomRows(canon) = contractAtoms + forwarding 조인(총청구금/미수금/상태). flat + 유닛/멤버 헤더 정렬(sortKey/sortDir/toggleSort). non-canon은 기존 cc행+펼침(v-else).
  • 진입 IA(§0-10): 주 식별자 클릭=상세(오버레이), table-hover+font-medium+hover:underline. 풀페이지 허브는 ↗(유닛만).
  • 수납 버튼: 완납에도 노출(추가납부=예수금), isCollectable = status !== '대손'.

수납 canon 컬럼 (트리아지) — 추가 이력

수납 3축 canon 컬럼 = 식별 + 시작/종료 + 총청구금 · 수납금 · 미수금 · 연체료 · 상태 · (전기) · 마지막수납일 · 수납처리. 컬럼 추가 시 헤더+메인행+canon 펼침 서브행(유닛/멤버; 계약 flat=서브행 無)+양 thead 5단계 전수 + Playwright 펼침 상태까지 검증(과거 1칸 시프트 회귀 빈발).

  • 마지막수납일(2026-06-28): useReceipts().lastReceivedAtForContracts(contractCodes) = 식별·미취소 영수증 중 max receivedAt. 축 매핑 = contract [a.contractCode] / unit unitOccupants(key).map(o=>o.contractCode) / member memberHoldings(key).map(h=>h.contractCode). 펼침 서브행은 행 단위([m.contractCode]/[u.contractCode]). 빈 값 . 위치 = 전기 뒤·수납처리 앞. as-of(과거) 행은 ''(범위 외).
  • 상태 부분수납 구분(2026-06-28): canon row status = total===0 ? 완납 : (Σperiods.collected>0 ? 부분수납 : 미수). 부분수납=앰버 뱃지(statusBadge)로 완전미납(빨강)과 분리. ⚠ 이는 컴포넌트 row 단위 — useForwarding.periodStatus(차수 단위)와 별개(테스트 무관).

3-1. 목록 툴바 — 실배선/정직비활성 구분 (2026-07-10)

수납 3축(collecting/{contract,member,unit}/blocks/DataToolBarOrg.vue)의 목록 툴바 버튼:

  • 고지서 — 캐논(isCanon = billing.line==='service-charge' && billing.edition==='actual')에서만 실배선. command="show-modal" commandfor="invoice-preview-art"MainOrg.vue에 마운트된 <dialog id="invoice-preview-art"><InvoiceDynamicOrg /></dialog>를 연다. 비캐논은 disabled title="준비 중이에요".
  • 명세서 · 시트 — export 백엔드 부재로 두 버튼 모두 항상 disabled title="준비 중이에요"(캐논 여부 무관).
  • 더보기(⋮, more_vert) — 수납 3축·청구(invoicing) 3축 ActionBarOrg.vue에서 완전 제거(원래 아무 기능도 연결돼 있지 않던 죽은 아이콘).

같은 원칙(캐논=실배선/비캐논 또는 백엔드 부재=disabled title="준비 중이에요")이 세금계산서 콘솔의 "전송 재시도" 버튼과 charging 콘솔의 항목명 드릴다운에도 적용됐다 — 각각 docs/handoff/backend/tax-documents.md §4-1, 아래 참고.

charging(부과/배분) 콘솔 항목명 드릴다운(펀치리스트 I-2, 실위치): assessment·allocation-{unit,member,contract}(actual)·allocation(provisional) 총 5화면에서 항목명 클릭이 999,999,999 정적 mock AccountDetailArt 오버레이를 열던 것을 제거 — 클릭 가능한 텍스트(<span class="font-medium">, affordance 없음) 또는 disabled 아이콘 버튼으로 전환. invoicing(청구) 3축의 동명 오버레이는 애초에 트리거가 없는 고아 마운트였음(조사 결과, 감사 원문의 위치 표기는 부정확했다) — 그쪽은 무변경.

4. 상세 시트 — 2종

  • 수납 canon = collecting/detail/blocks/SheetCollectingDetailArt.vue(id sheet-collecting-detail-art). selectedCollecting={axis,key}detailFor. 식별부(detail.identity) + AllocationStatusOrg(차수) + 수납처리/선수금/수납내역/조정. 제목 axis별("유닛/멤버 통합 고지서"/"계약 상세").
  • 청구 + non-canon = overlays/SheetReadBillingArt.vue(id sheet-read-billing-art). selectedBillingaxis+key 있으면 detailFor로 실데이터(식별부·AllocationStatusOrg·총청구금·선수금·수납내역), 없으면 데모 폴백(v-if="detail"/v-else). ⚠ 청구 unit/member/contract MainOrg 모두 이 시트 마운트 필요(과거 unit/member 누락 버그).
  • AllocationStatusOrg(collecting/detail/blocks) = :periods 받아 차수별 미수·수납 렌더(재사용). 도출근거는 sheet-derivation-art(있으면).

5. 부과 행렬 (charging)

  • 행 = charges(부과항목), 열 = 축 엔티티({contract,unit,member}Columns), 셀 = cellAmount(charge, group) = Σ amountsByCharge[charge.id][line.id] over group의 그 charge 라인. 합계행(columnTotal) + 부제(호수/점유자/보유유닛, 다수=외 N).
  • 금지 패턴(고친 버그): 열을 flatMap(g.lines)(charge×unit 중복)로 잡고 amountsByCharge[charge][cell.line.id] 직접 → off-diagonal NaN. 반드시 엔티티 1단계 + charge 필터 합산.
  • typeLabel: 과세/면세/영세/비수익만 매핑, 그 외(장충=해당없음)는 원값(과세 오표시 금지).

5-1. useAllocations.byContract/byUnit/byMember(charge) — 공개 피벗 (FB-31, 2026-07-10)

이 위 cellAmount와 별개로, useAllocations는 charge 1개를 받아 축별 그룹×합계를 돌려주는 공개 피벗 3종(byContract/byUnit/byMember)도 export한다(useAllocations.js:188-217). 현재 소비처 0(export만 존재, 어떤 컴포넌트도 import하지 않음) — 그래서 §5의 cellAmount와 똑같은 "엔티티 컬럼의 g.lines는 charge 무관 전체 라인, allocate(charge)는 그 charge 라인에만 값 존재" 함정을 그대로 갖고 있었는데도 이번 웨이브 전까지 아무도 걸리지 않았다(FB-26류 "시한폭탄" — 배선되는 순간 즉발).

  • 수정: g.lines.filter(c => amounts[c.line.id] !== undefined)로 charge-무관 라인을 먼저 제외한 뒤 매핑·합산, 결과 cells.length===0인 그룹은 통째로 드롭. 세 피벗 동일 패턴.
  • 불변식 테스트(useAllocations.spec.js): 3개 피벗 모두 Number.isNaN(g.total)===false · 각 셀 amount!==undefined · Σg.total === useCharges().amountsOf(charge).chargePrincipal(브리핑이 가정한 chargePrincipal 직접 export는 없음 — useCharges().amountsOf(charge).chargePrincipal로 대체).
  • 이 3종을 새로 소비하는 화면을 만들 때는 위 필터가 이미 적용된 채로 값을 받게 되므로 별도 방어 코드 불요 — 단 빈 그룹이 배열에서 아예 빠진다(0건 그룹이 아니라 부재)는 점을 렌더 쪽에서 인지해야 한다.

6. 확장 포인트 / 주의

  • 계약 = 1유닛(호실별). 멤버 다유닛 = 계약 N개. 펼침/atom = 단일 호, 멤버명 클릭만 멀티유닛 합산.
  • 시드 = demoSeed.js(15세대, 비용 EXP_SCALE 비례). 행 수 조절 = DEMO_CONFIG.householdCount.
  • 신규 축/시트 변경 시: 본 문서 + BE [[tenancy-axes-billing.md]] + 사용법 매뉴얼 동반 갱신(3종 의무).
  • 게이트: npm run precommit:check(DS) + vitest + build.