Skip to content

BE 참고 — 관리비 콘솔 "회계 전기" 실배선 (전기 C-2)

독자: BE 개발자/AI. 관리비 수납·청구 콘솔의 "회계 전기" 버튼과 "전기" 배지를 useGeneralLedger 상태머신에 실배선한 슬라이스(punch-list Critical C-2)의 계약을 정의한다. 정본 설계: docs/superpowers/specs/2026-07-09-billing-console-gl-posting-design.md. 실행 로그: .superpowers/sdd/{task-1,task-2,task-3}-report.md. 참조 선례: docs/handoff/backend/purchasing-p1-p2.md §5(매입 지급 콘솔의 동형 전기 배선 — usePurchaseJournal/useGeneralLedger.post).

1. 배경 — as-was

이전에는 수납·청구 콘솔의 "회계 전기" 버튼이 범용 DialogConfirmArt(cosmetic 다이얼로그)를 열 뿐 아무 상태도 바꾸지 않았고, 목록의 "전기" 배지는 postingStatus: '미전기' 리터럴로 영구 고정돼 있었다. 상태머신(useGeneralLedger — module-level reactive(Set) postedIds + post(id)/isPosted(id))은 이미 존재했고 회계앱(BillingIntakeOrg)과 매입 지급 콘솔(purchasing/console/payment)이 소비하고 있었지만, 관리비 수납·청구 콘솔은 배선돼 있지 않았다.

2. 신규 상태머신 = 0

이번 슬라이스는 useGeneralLedger를 그대로 재사용한다. 새 posted/unposted 플래그, 새 Set, 새 API를 추가하지 않았다. 콘솔이 호출하는 함수는 기존에 이미 있던 3개뿐이다.

js
const { post, isPosted } = useGeneralLedger();

postedIds는 module-level singleton이라 콘솔·회계앱·매입 콘솔이 동일한 id 키 공간을 공유한다. 전표 id 문자열이 곧 전기 상태의 유일한 키다(voucher 종류·소유 컴포저블과 무관 — bj-*(빌링), pj-*(매입) 모두 같은 Set에 들어간다).

3. 전표 스코프 파티션 — useBillingJournal 신규 접근자 (Task 1)

useBillingJournal.js에 콘솔 스코프 재노출용 얇은 합성 접근자 2개를 추가했다(기존 8개 그룹 함수의 순수 재조합 — 그룹 함수 자체의 로직은 무변경).

js
collectionVouchers(); // = vouchersCollection() ∪ vouchersAdjustments() ∪ vouchersAdvanceMovements()
invoicingVouchers(); // = vouchersInvoicing() ∪ vouchersOpeningReceivable() ∪ vouchersOpeningAdvance()
//   ∪ vouchersLateFeeAccrual() ∪ vouchersExpense()
함수포함 이벤트소비 콘솔
collectionVouchers()수납·가수금·가수금재분류·대손확정·매출에누리·연체료면제·선수금충당·선수금환급수납(collecting)
invoicingVouchers()청구확정·전기이월·개시선수금·연체료발생·비용발생청구(invoicing)

불변식(파티션)useBillingJournal.spec.js(describe "콘솔 스코프 파티션")로 검증:

  1. 완전성(union): [...collectionVouchers(), ...invoicingVouchers()]의 id 집합 === billingVouchers()의 id 집합.
  2. 서로소(disjoint): collectionVouchers() ∩ invoicingVouchers() = ∅.
  3. 각 파티션 내부 id 중복 없음, 이벤트 타입이 표에 명시된 허용 집합 안에만 존재.

이 두 함수를 소비하는 것은 콘솔의 [회계 전기] 버튼과 unpostedCount 계산뿐이다 — 배지 파생에는 사용하지 않는다(§4·§5 참조. 배지는 별도의 행-스코프 문제라 파티션 함수만으로는 풀리지 않는다).

4. 수납 콘솔 — 배지 = 유닛/멤버 롤업 + 프리픽스 커버리지 가드 (Task 2, Critical)

4-1. 행↔전표 카디널리티 문제

청구와 달리 수납 콘솔의 행은 유닛/멤버 집계(여러 계약·여러 이벤트의 전표를 한 행에 묶음)라서, "이 행에 속한 전표 id 목록"을 직접 얻을 방법이 없다(voucher 객체 자체에 contractCode 필드가 없고, 이번 슬라이스는 voucher shape을 바꾸지 않는 것을 전제로 했다). 해결책은 후보 id 재구성 + 존재 필터링이다:

그 행의 계약 목록(unitOccupants/memberHoldings)
  → 각 계약의 receipts/adjustments/advanceLedger를 순회하며
    "collectionVouchers()가 생성했을 법한" id를 후보로 조립
      bj-rcp-<receiptId> / bj-sus-<receiptId> / bj-reclass-<receiptId>   (receipts, status!=='취소')
      bj-adj-<adjustmentId>                                              (adjustments, status!=='취소')
      bj-adv-apply-<cc>-<seq> / bj-adv-refund-<cc>-<seq>                 (advanceLedger(cc), kind별, seq=1-base index)
  → collectionVouchers()가 실제로 emit한 id 집합과 교집합만 채택
  → ids.length && ids.every(isPosted) ? '전기완료' : '미전기'   (빈 집합이면 '미전기' 폴백)

이 로직은 collecting/{unit,contract,member}/blocks/DynamicTableOrg.vue 3개 파일에 각각 복제돼 있다(기존 statusBadge/postingBadge 중복 컨벤션과 동일 — 4번째 소비처가 생기면 공유 컴포저블로 승격 검토).

스코프(계약 목록)적용 위치
unitunitOccupants(unitKey).map(o => o.contractCode)buildCanonRows() + unitRowsAsOf()(과거 조회 분기)
membermemberHoldings(memberKey).map(h => h.contractCode)buildCanonRows() + memberRowsAsOf()
contract[a.contractCode](단일 계약)atomRows이전에는 전기 배지 자체가 없던(영구 빈 <td>) 데드 컬럼이었다, 이번에 처음 배선

advanceLedger(cc)seq원장 배열의 0-base index를 1-base로 변환(String(i+1).padStart(4,'0')) — useBillingJournal.vouchersAdvanceMovements()가 같은 advanceLedger(cc)를 같은 순서로 순회해 동일 seq를 부여하므로 오늘은 일치하지만, 이는 암묵적 계약이다(명시적 인터페이스 아님). useBillingJournal.js가 advance-movement id의 seq 부여 방식을 바꾸면 3개 DynamicTableOrg.vue의 재구성 로직도 함께 바꿔야 한다.

4-2. 프리픽스 커버리지 가드 (전기 C2-2b — Important 결함 수정)

최초 구현은 "후보 id가 collectionVouchers()에 없으면 안전하게 무시된다(safe-failure)"고 가정했으나, 다중 계약/다중 전표 스코프에서는 거짓 양성이 가능하다는 코드 리뷰 지적으로 정정됐다:

  • 한 행에 전표가 2개 이상 있을 때, 그중 인식된 프리픽스의 전표만 전기완료이고 미인식(신규 추가된) 프리픽스의 전표가 미전기로 남아있으면, 미인식 전표는 애초에 후보 목록에 없으므로 ids에서 조용히 빠지고 ids.every(isPosted)true를 반환한다 → 행이 실제로는 미전기 전표를 갖고 있는데도 '전기완료'로 오표시.

가드: useBillingJournal.spec.js에 "collectionVouchers id 접두사 커버리지 가드" describe 블록을 추가. 6종 수납 전표(수납/가수금/가수금재분류/조정/선수금충당/선수금환급) 각 1건씩 생성해 collectionVouchers()가 emit하는 id 프리픽스 집합이 정확히 {bj-rcp-, bj-sus-, bj-reclass-, bj-adj-, bj-adv-apply-, bj-adv-refund-}와 같은지 단언한다. useBillingJournal.js가 새 collection-voucher id 모양을 추가하는 순간 이 테스트가 즉시 실패해, 3개 DynamicTableOrg.vue의 후보 목록을 잊지 않고 동시 갱신하도록 강제한다.

BE가 새 수납측 전표 종류를 추가할 때 반드시 할 일:

  1. useBillingJournal.js에 새 그룹 함수 추가 + collectionVouchers()(또는 invoicingVouchers()) 재조합에 편입.
  2. 위 커버리지 가드 테스트의 기대 프리픽스 집합 갱신.
  3. collecting/{unit,contract,member}/blocks/DynamicTableOrg.vue 3개 파일의 후보 id 생성 목록에 새 프리픽스 추가(안 하면 §4-1의 거짓 양성 재발).

5. 청구 콘솔 — 배지 = 직접 파생, 단일 프리픽스 (Task 3)

청구 콘솔은 행 = 계약 단위(청구확정 전표와 사실상 1:1)라서 매입 지급 콘솔과 동일하게 직접 파생이면 충분하고, 커버리지 가드가 불필요하다.

js
contractPostingStatus(contractCode) = isPosted('bj-inv-' + contractCode) ? '전기완료' : '미전기'
  • contract axis: isPosted('bj-inv-'+contractCode) 1:1.
  • unit/member axis: 스코프 내 계약들의 bj-inv-<cc> id를 조립 → invoicingVouchers()가 실제로 emit한 id와 교집합(빈 집합 가드 — 계약이 아예 없거나 청구확정 전표가 없는 스코프에서 .every()가 진공적으로 true가 되는 것을 방지) → 전부 존재+전기완료여야 '전기완료'.

주의(문서화된 스코프 불일치): 청구 배지는 청구확정(bj-inv-) 전표만 본다. invoicingVouchers()(버튼이 전기하는 범위)에는 개시이월·개시선수금·연체료발생·비용발생도 포함되지만, 배지는 이들을 반영하지 않는다 — 배지 판정 범위 ⊊ 버튼 전기 범위. spec 설계 결정 §4에 명시된 대로다(청구확정만 배지로 보는 것이 사용자에게 의미 있는 신호이기 때문 — 그 외 전표는 행 단위로 귀속되지 않는 개시/발생성 전표).

6. accountingEnabled 게이트 vs useClosing 게이트 — 의도적 분리

  • useModuleSubscription().accountingEnabled: "회계 모듈 구독 여부" — 콘솔이 이 게이트만 가져다 쓴다(v-if="accountingEnabled"로 버튼·배지 컬럼 숨김).
  • useClosing(마감기간): 별도 컴포저블이 소유. useGeneralLedger.postAllUnposted(gate)는 선택적 gate 파라미터를 받아 마감 이후 전기를 차단할 수 있지만, 이번 슬라이스의 콘솔은 이 게이트를 주입하지 않는다postAllUnposted가 아니라 for (const v of vouchers) if (!isPosted(v.id)) post(v.id) 형태의 ungated 루프(매입 지급 콘솔 선례와 동형)를 쓴다.
  • 이유(순환 회피): 콘솔이 useClosing을 직접 import하면 의존 그래프가 복잡해진다. 마감기간 차단이 필요한 소비처(BillingIntakeOrg)는 이미 자체적으로 useClosing.canPost 게이트를 postAllUnposted(gate)에 주입해 처리한다 — 관리비 콘솔에 동일 게이트를 들이는 것은 스코프 외(후속 검토 대상, §8).

7. 불변식 요약

#불변식근거
collectionVouchers()invoicingVouchers()는 서로소이고 합집합은 billingVouchers()와 같다useBillingJournal.spec.js "콘솔 스코프 파티션"
수납 콘솔 [회계 전기]는 청구 전용 전표(bj-inv-* 등)를 전기하지 않는다, 그 역도 성립①의 서로소성 + 각 콘솔이 자기 스코프 함수만 순회
전기 후 배지 전이: 직전 '미전기'였고 스코프에 전표가 1개 이상 있던 행은, 그 전표들을 모두 전기하면 '전기완료'로 바뀐다ActionBarOrg.posting.spec.js(unit/contract/member × collecting/invoicing, 총 6개 스펙 파일)
스코프에 전표가 0개인 행은 항상 '미전기'로 폴백한다(진공적 .every() true 오탐 방지)unit/member 축 모두 빈 집합 가드 명시
이미 전기된 전표를 다시 post()해도 상태는 멱등(Set 특성)useGeneralLedger.post = (id) => postedIds.add(id)
신규 상태머신 0 — postedIds Set은 매입(pj-*)·빌링(bj-*) 전 콘솔 공유useGeneralLedger는 module-level singleton, id 문자열만으로 범용 조회

8. As-built vs 후속

As-built(이번 슬라이스): 수납·청구 콘솔(각 unit/contract/member 3축) [회계 전기] 버튼 실배선 + 미전기 건수 라벨 + "전기" 배지 실파생(수납=롤업+커버리지가드, 청구=직접파생) + accountingEnabled 게이팅.

스코프 외(후속):

  • 저장/확정/수납시작/청구시작 CTA — 별도 stage-status 모델 필요(punch-list C-1, 이 슬라이스와 별개).
  • [회계 전기] 확인 다이얼로그 — 현재 즉시 전기(매입 선례 동형). 필요 시 전용 핸들러가 소유(범용 DialogConfirmArt 재사용 금지 — cosmetic 전례가 이번에 제거된 것과 같은 함정 재발 방지).
  • 전기 취소(unpost) 화면 UI — 컴포저블(unpost)은 존재하나 버튼 미배선.
  • 콘솔에 useClosing 마감 게이트 주입 — 현재 ungated. 마감 이후 전기 차단이 필요해지면 BillingIntakeOrgpostAllUnposted(gate) 패턴을 콘솔에도 이식 검토.
  • 수납 롤업의 후보 id 재구성 로직 3파일 복제 — 4번째 소비처 발생 시 공유 컴포저블 승격 검토.

9. 관련 파일

레이어파일
전표 스코프 파티션src/composables/useBillingJournal.js(collectionVouchers·invoicingVouchers)
전기 상태머신(재사용, 무변경)src/composables/useGeneralLedger.js(post·isPosted)
수납 콘솔 액션바src/components/billing/_core/console/collecting/{unit,contract,member}/blocks/ActionBarOrg.vue
수납 콘솔 배지 롤업src/components/billing/_core/console/collecting/{unit,contract,member}/blocks/DynamicTableOrg.vue
청구 콘솔 액션바src/components/billing/_core/console/invoicing/{unit,contract,member}/blocks/ActionBarOrg.vue
청구 콘솔 배지 직접파생src/components/billing/_core/console/invoicing/{unit,contract,member}/blocks/DynamicTableOrg.vue
테스트src/composables/__tests__/useBillingJournal.spec.js(파티션·커버리지 가드) · collecting/·invoicing/ 각 축 __tests__/ActionBarOrg.posting.spec.js(신규 6파일)
매입 참조 선례docs/handoff/backend/purchasing-p1-p2.md §5