다크모드
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 "콘솔 스코프 파티션")로 검증:
- 완전성(union):
[...collectionVouchers(), ...invoicingVouchers()]의 id 집합 ===billingVouchers()의 id 집합. - 서로소(disjoint):
collectionVouchers() ∩ invoicingVouchers() = ∅. - 각 파티션 내부 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번째 소비처가 생기면 공유 컴포저블로 승격 검토).
| 축 | 스코프(계약 목록) | 적용 위치 |
|---|---|---|
| unit | unitOccupants(unitKey).map(o => o.contractCode) | buildCanonRows() + unitRowsAsOf()(과거 조회 분기) |
| member | memberHoldings(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가 새 수납측 전표 종류를 추가할 때 반드시 할 일:
useBillingJournal.js에 새 그룹 함수 추가 +collectionVouchers()(또는invoicingVouchers()) 재조합에 편입.- 위 커버리지 가드 테스트의 기대 프리픽스 집합 갱신.
collecting/{unit,contract,member}/blocks/DynamicTableOrg.vue3개 파일의 후보 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. 마감 이후 전기 차단이 필요해지면BillingIntakeOrg의postAllUnposted(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 |