다크모드
FE 메인테이너 — 관리비 콘솔 "회계 전기" 배선 (전기 C-2)
독자: FE 메인테이너/AI. "이 배선을 이어 개발하려면 무엇을 알아야 하나"에 집중한다. BE 계약:
docs/handoff/backend/billing-console-gl-posting.md(전표 스코프 파티션·롤업 규칙·불변식). 정본 설계:docs/superpowers/specs/2026-07-09-billing-console-gl-posting-design.md. 매입 미러 관계:docs/handoff/frontend/purchasing-p1-p2.md(동일 패턴의 원조 —payment/MainOrg.vue).
1. 무엇이 바뀌었나 (as-was → as-built)
| as-was | as-built | |
|---|---|---|
| 버튼 | command="show-modal" commandfor="dialog-confirm-art"(범용 cosmetic 다이얼로그, 확인해도 command="close"뿐) | `@click="postAll{Collecting |
| 배지 | postingStatus: '미전기' 리터럴 고정(contract axis는 배지 자체가 없는 빈 <td>) | isPosted() 기반 실파생(축별 규칙, §4) |
2. 배선 대상 (6개 콘솔 × 2 파일)
src/components/billing/_core/console/
├── collecting/{unit,contract,member}/blocks/
│ ├── ActionBarOrg.vue ← 3파일 byte-identical
│ └── DynamicTableOrg.vue ← 축별로 다름(스코프 계약 목록 도출 방식이 축마다 다름)
└── invoicing/{unit,contract,member}/blocks/
├── ActionBarOrg.vue ← 3파일 byte-identical
└── DynamicTableOrg.vue ← 축별로 다름3. ActionBarOrg.vue — 소비 컴포저블 + 배선
두 콘솔군의 ActionBarOrg.vue는 각 3파일이 byte-identical(수납군 서로 동일, 청구군 서로 동일 — 수납군과 청구군 사이는 collectionVouchers/invoicingVouchers, postAllCollecting/postAllInvoicing, 버튼 텍스트("수납시작"/"청구시작") 정도만 다르다).
js
import { computed } from "vue";
import { useModuleSubscription } from "@/composables/useModuleSubscription";
import { useGeneralLedger } from "@/composables/useGeneralLedger";
import { useBillingJournal } from "@/composables/useBillingJournal";
const { accountingEnabled } = useModuleSubscription();
const { post, isPosted } = useGeneralLedger();
const { collectionVouchers } = useBillingJournal(); // 청구군은 invoicingVouchers
const unpostedCount = computed(() => collectionVouchers().filter((v) => !isPosted(v.id)).length);
const postAllCollecting = () => {
for (const v of collectionVouchers()) if (!isPosted(v.id)) post(v.id);
};html
<button
v-if="accountingEnabled"
@click="postAllCollecting"
:disabled="unpostedCount === 0"
class="button button-md button-neutral-subtle"
>
회계 전기{{ unpostedCount ? ` (${unpostedCount})` : '' }}
</button>저장/확정/수납시작(또는 청구시작)/more_vert 버튼은 손대지 않았다 — 전부 C-1(별도 스테이지 CTA 슬라이스) 스코프.
바이트 동일성 유지 규칙: 3축(unit/contract/member) ActionBarOrg.vue는 앞으로도 byte-identical을 유지해야 한다. 수정 시 diff로 3파일 동일성을 재확인할 것(Task 2/3 리포트가 매 커밋 후 diff 검증했던 방식 그대로).
4. DynamicTableOrg.vue — 배지 파생 (축마다 다름, 매입과의 결정적 차이)
매입 콘솔(purchasing/console/payment/blocks/DynamicTableOrg.vue)은 행=전표 1:1이라 isPosted('pj-pay-'+p.id) 한 줄로 끝나지만, 관리비 콘솔은 행의 카디널리티가 축마다 달라서 3파일 각각 다른 스코프 도출 로직을 갖는다.
4-1. 수납 콘솔 — 유닛/멤버 롤업 + 후보 id 재구성
js
const { collectionVouchers } = useBillingJournal();
const { isPosted } = useGeneralLedger();
const collectionVoucherIdSet = computed(() => new Set(collectionVouchers().map((v) => v.id)));
const collectionIdsForContracts = (contractCodes) => {
// scope 내 receipts/adjustments/advanceLedger를 순회해 "있을 법한" 후보 id 조립
// (bj-rcp- / bj-sus- / bj-reclass- / bj-adj- / bj-adv-apply- / bj-adv-refund-)
// → collectionVoucherIdSet과 교집합만 채택
};
const unitPostingStatus = (unitKey) => {
const ids = collectionIdsForContracts(unitOccupants(unitKey).map((o) => o.contractCode));
return ids.length && ids.every(isPosted) ? "전기완료" : "미전기";
};| 축 | 스코프 도출 | 적용 위치 |
|---|---|---|
| unit | unitOccupants(unitKey) | buildCanonRows() + unitRowsAsOf()(과거 시점 조회 분기도 동일 규칙) |
| member | memberHoldings(memberKey) | buildCanonRows() + memberRowsAsOf() |
| contract | 단일 계약([a.contractCode]) | atomRows — 과거엔 배지 자체가 없던 빈 <td>였다(데드 컬럼), 이번에 처음 배선 |
mockCollectings(비-canon 데모 배열)와 buildProvisionalRows()(잠정정산, '예수금' 배지)는 손대지 않았다 — 잠정정산의 '예수금'은 전기 개념이 아니라 선수금성 상태라 롤업 대상이 아니다.
⚠ 이 로직을 수정/확장할 때 반드시 알아야 할 것: 후보 id 목록(bj-rcp-/bj-sus-/bj-reclass-/bj-adj-/bj-adv-apply-/bj-adv-refund-)은 useBillingJournal.collectionVouchers()가 emit하는 프리픽스와 수동으로 동기화된 하드코딩 목록이다. BE(useBillingJournal.js)가 새 수납측 전표 종류를 추가하면:
useBillingJournal.spec.js의 "collectionVouchers id 접두사 커버리지 가드" 테스트가 실패한다(의도된 신호).- 그 실패를 보고 3개
DynamicTableOrg.vue(collecting/unit·collecting/contract·collecting/member)의 후보 목록에 새 프리픽스를 추가해야 한다. - 안 하면? 새 전표 종류가 있는 행에서 "다른 전표는 인식+전기완료, 새 전표는 미인식(후보 목록에 없어 조용히 누락)"인 경우
.every(isPosted)가 인식된 것만 보고true를 반환 — 거짓 '전기완료'(전기 C2-2b에서 정정된 리스크, §BE 문서 §4-2 참조).
advanceLedger(cc) 기반 후보(bj-adv-apply-/bj-adv-refund-)의 seq는 배열 index(1-base, zero-pad 4자리)를 그대로 쓴다 — useBillingJournal.vouchersAdvanceMovements()가 같은 배열을 같은 순서로 순회한다는 암묵적 전제에 의존한다(명시적 계약 아님, 코드 주석으로 표시됨).
4-2. 청구 콘솔 — 직접 파생, 단일 프리픽스
js
// contract axis
const contractPostingStatus = (contractCode) =>
isPosted("bj-inv-" + contractCode) ? "전기완료" : "미전기";
// unit/member axis
const unitPostingStatus = (unitCode) => {
const contractCodes = [...new Set(unitOccupants(unitCode).map((o) => o.contractCode))];
const ids = contractCodes
.map((cc) => "bj-inv-" + cc)
.filter((id) => invoicingVoucherIdSet.value.has(id));
return ids.length && ids.every(isPosted) ? "전기완료" : "미전기";
};프리픽스가 bj-inv- 하나뿐이라 커버리지 가드 테스트는 두지 않았다(BE 문서 §5 근거). unit/member는 그래도 빈 집합 가드(ids.length &&)가 필요 — 계약이 없거나 청구확정 전표가 아직 없는 스코프에서 [].every(...)가 진공적으로 true가 되어 거짓 전기완료를 내는 것을 막는다.
5. 매입 콘솔과의 관계 — 무엇이 같고 무엇이 다른가
매입 지급 콘솔(purchasing/console/payment) | 관리비 수납/청구 콘솔 | |
|---|---|---|
| 상태머신 | useGeneralLedger(공유) | useGeneralLedger(공유, 동일 singleton) |
| 행↔전표 카디널리티 | 1:1(pj-pay-<id>) | 청구=1:1(contract axis)/N:1(unit·member axis), 수납=항상 N:1 |
| 배지 파생 | isPosted('pj-pay-'+p.id) 직접 | 청구=직접(contract)/롤업(unit·member), 수납=전부 롤업+후보재구성 |
| 확인 다이얼로그 | 없음(즉시 전기) | 없음(즉시 전기, 동형) |
| 게이팅 | accountingEnabled | accountingEnabled(동형) |
매입 콘솔을 다시 만질 일이 있다면 이 문서의 §4-1(후보 id 재구성)은 참고할 필요 없다 — 매입은 항상 1:1이라 이 복잡도가 아예 발생하지 않는다.
6. 확장 포인트
- [회계 전기] 확인 다이얼로그: 현재 즉시 전기. 필요해지면 콘솔별 전용 핸들러(
postAllCollecting/postAllInvoicing앞에 확인 스텝)를 새로 만들 것 — 범용DialogConfirmArt재사용 금지(이번에 제거한 cosmetic 배선과 같은 실수 재발 방지: "다이얼로그는 열리는데 확인해도 아무 일도 안 일어난다"는 이번 결함의 원인 그 자체였다). - 전기 취소(unpost) UI:
useGeneralLedger().unpost(id)는 이미 존재하나 관리비 콘솔에 버튼이 없다. 추가하려면 어느 스코프(전표 단위? 행 단위 일괄?)로 노출할지부터 결정 필요 — 수납 행은 N:1이라 "이 행 전체를 되돌리기"가 여러 전표에 걸친 일괄 unpost가 된다. - 마감기간 게이트(
useClosing) 주입: 현재 콘솔은 ungated 루프(for (const v of vouchers) if (!isPosted(v.id)) post(v.id)).useGeneralLedger.postAllUnposted(gate)가 게이트 파라미터를 지원하므로, 필요 시BillingIntakeOrg의closing.canPost주입 패턴을 그대로 이식 가능(순환 회피 이유로 이번엔 스코프 아웃 — BE 문서 §6). - 수납 롤업 로직의 4번째 소비처: 현재 3파일(
collecting/{unit,contract,member})에 복제된 후보 id 재구성 + 롤업 로직을 공유 컴포저블(useCollectionPostingRollup같은)로 승격하는 것을 검토할 시점 — 청구축도 유사 패턴을 쓰므로 통합 여지 있음. - 청구 배지의 스코프 확장: 현재 청구 배지는
bj-inv-(청구확정)만 본다. 개시이월·연체료발생 등도 배지에 반영하려면 §4-2를 수납측 후보-재구성 패턴(§4-1)으로 교체해야 한다(멀티 프리픽스가 되므로 커버리지 가드도 함께 도입 필요).
7. 테스트 위치
src/composables/__tests__/useBillingJournal.spec.js
— describe "콘솔 스코프 파티션"(collectionVouchers/invoicingVouchers 완전성·서로소)
— describe "collectionVouchers id 접두사 커버리지 가드"(전기 C2-2b)
src/components/billing/_core/console/collecting/unit/blocks/__tests__/ActionBarOrg.posting.spec.js
src/components/billing/_core/console/collecting/contract/blocks/__tests__/ActionBarOrg.posting.spec.js
src/components/billing/_core/console/collecting/member/blocks/__tests__/ActionBarOrg.posting.spec.js
src/components/billing/_core/console/invoicing/unit/blocks/__tests__/ActionBarOrg.posting.spec.js
src/components/billing/_core/console/invoicing/contract/blocks/__tests__/ActionBarOrg.posting.spec.js
src/components/billing/_core/console/invoicing/member/blocks/__tests__/ActionBarOrg.posting.spec.js각 스펙은 버튼→전기 후 배지 전이(미전기→전기완료), unpostedCount 라벨 정확도, "이 콘솔 전기가 다른 콘솔 전용 전표는 건드리지 않는다"(스코프 분리), accountingEnabled=false 게이팅(버튼+배지 컬럼 숨김)을 커버한다. 멤버 축은 다계약 보유(memberHoldings 2개 이상) 케이스로 .every() 롤업이 "첫 계약만 확인"이 아님을 별도 검증한다.