• #連結会計
  • #Vue
  • #Miller Columns
  • #Vitest
  • #連結精算表
  • #Nuxt
開発eurekapuメモ

連結精算表インタラクティブビューアーの設計と実装

連結精算表は横に長い。スプレッドシートで見ても、P社・A社の個別F/S(財務諸表)から始まり、単純合算、個別修正、連結修正仕訳(資本連結、のれん、内部取引、未実現利益)、組替仕訳、連結財務諸表と続く。画面を何度もスクロールするのは辛い。

前提: 本記事の会計処理は日本基準に準拠する。のれんは20年以内の期間で規則的に償却する(IFRSでは非償却)。連結範囲はP社(親会社)+ A社(子会社、80%保有)の2社。B/S = 貸借対照表、P/L = 損益計算書、S/S = 株主資本等変動計算書。

そこで、Miller Columns(macOS Finderで見かけるあれ)パターンで精算表をシートごとに分割し、シート間をリンクやキーボードで移動できるビューアーを作った。

完成したUI

連結精算表ビューアーの完成画面

左から「論点」「中カテゴリ」「小カテゴリ」「コンテンツ」の4カラムが並ぶ。このスクリーンショットでは連結精算表(全体)が選択されており、ZoomableTableContainerで75%に縮小された全シートのデータを一覧できる。B/S・S/S・P/Lの各セクションは区分列で視覚的に区切られ、sticky headerとsticky列により横スクロールしても勘定科目名とヘッダーが消えない。

全体構成

最終的に以下のファイルができた。

apps/web/app/
  pages/consolidated-worksheet/
    index.vue                     # メインページ(Miller Columns レイアウト)
  components/consolidated-worksheet/
    WorksheetTable.vue            # 連結ワークシート表示
    ConsolidatedFsTable.vue       # 連結財務諸表(勘定式レイアウト)
    IndividualFsTable.vue         # 個別財務諸表(勘定式レイアウト)
    JournalEntryCard.vue          # 個別仕訳カード
    JournalCategoryTable.vue      # 仕訳カテゴリ一覧テーブル
    PrerequisitesCard.vue         # 前提条件ページ(SVG資本関係図あり)
    ZoomableTableContainer.vue    # ズーム付きコンテナ
    data/
      types.ts                    # 型定義
      dataset-registry.ts         # Year1/Year2 データセット切替
      worksheet-data.ts           # 1年目データ
      worksheet-data-year2.ts     # 2年目データ
tests/
  consolidated-worksheet.test.ts  # 貸借一致・水平整合性・期間連続性テスト

Miller Columns: 3カラムから4カラムへ

当初は macOS Finder 風の3カラム(大カテゴリ / 中カテゴリ / コンテンツ)で設計した。しかし実装を進めるうちに「中カテゴリ」の下にさらに「小カテゴリ」(例: ワークシート内の各シート、仕訳カテゴリ内の各カテゴリ)が必要になり、4カラムに拡張した。

┌──────────┬───────────┬──────────┬─────────────────────┐
│ 第1カラム │ 第2カラム  │ 第3カラム │ 第4カラム(コンテンツ)│
│   論点    │ 中カテゴリ │ 小カテゴリ│                      │
├──────────┼───────────┼──────────┼─────────────────────┤
│ Year 1   │ 前提条件 ← │         │ SVG資本関係図         │
│ Year 2 ← │ 個別F/S   │          │ 前提条件テーブル      │
│          │ 連結修正   │          │                      │
│          │ ワークシート│          │                      │
└──────────┴───────────┴──────────┴─────────────────────┘

「前提条件」を選んだときは小カテゴリが不要なので、3カラムに自動切替する。CSSのgrid-template-columnsを動的に変える。

<div class="miller-columns"
  :class="{ 'no-small-column': selectedMidCategory === 'prerequisites' }">
.miller-columns {
  display: grid;
  grid-template-columns: 180px 160px 180px 1fr;
}
.miller-columns.no-small-column {
  grid-template-columns: 180px 160px 1fr;
}

3階層ナビゲーション + コンテンツ

第1カラム: 論点(大カテゴリ)

Year 1 と Year 2 の2つ。論点を切り替えると、データセット全体が切り替わる。

const topics: Topic[] = [
  { id: 'year1', name: '演習論点x1年目' },
  { id: 'year2', name: '演習論点x2年目' },
]

データセットの切替は dataset-registry.ts で管理する。

const datasetRegistry = new Map<string, WorksheetDataset>([
  ['year1', year1Dataset],
  ['year2', year2Dataset],
])

export const getDataset = (topicId: string): WorksheetDataset =>
  datasetRegistry.get(topicId) ?? year1Dataset

第2カラム: 中カテゴリ

中カテゴリ表示内容
前提条件SVG資本関係図、基本前提テーブル
個別財務諸表P社・A社の勘定式B/S、P/L、S/S
連結修正仕訳仕訳カテゴリ別のテーブル
連結ワークシート各シートの精算表

第3カラム: 小カテゴリ

中カテゴリに応じて表示内容が変わる。

const smallItems = computed(() => {
  const ds = currentDataset.value
  if (selectedMidCategory.value === 'prerequisites') return []
  if (selectedMidCategory.value === 'worksheet')
    return ds.sheets.map(s => ({ id: s.id, name: s.name }))
  if (selectedMidCategory.value === 'journal')
    return ds.journalCategories.map(c => ({ id: c.id, name: c.name }))
  return ds.individualCompanies.map(c => ({ id: c.id, name: c.name }))
})

連結ワークシートの表示

B/S, S/S, P/L の並び順

最初は B/S, P/L, S/S の順にしていたが、連結精算表の流れとしては B/S -> S/S -> P/L の順が自然。S/Sは利益剰余金の変動を示し、B/Sの利益剰余金とP/Lの当期純利益を繋ぐ役割がある。

<tbody v-for="(sectionRows, sIdx) in [
  { key: 'bs', label: '連結B/S', rows: bsRows },
  { key: 'ss', label: '連結S/S', rows: ssRows },
  { key: 'pl', label: '連結P/L', rows: plRows },
]" :key="sectionRows.key">

ワークシートヘッダーの2行化

2年目のワークシートでは「開始仕訳」と「当期仕訳」のグループ分けが必要になる。headerGroups を導入して、ヘッダーを2行にした。

interface ColumnGroup {
  label: string
  columnIds: string[]
}

interface WorksheetSheet {
  id: string
  name: string
  columns: SheetColumn[]
  headerGroups?: ColumnGroup[]
  // ...
}

1行目にはグループラベル(colspan)、2行目にはグループ内の個別カラム名を表示する。グループに属さないカラムは rowspan=2 で1行目に配置。

const headerRow1 = computed<HeaderRow1Cell[]>(() => {
  const groups = props.sheet.headerGroups!
  const cells: HeaderRow1Cell[] = []
  let i = 0
  const cols = props.sheet.columns
  while (i < cols.length) {
    const col = cols[i]
    const group = groups.find(g => g.columnIds[0] === col.id)
    if (group) {
      cells.push({ type: 'group', groupLabel: group.label, colspan: group.columnIds.length })
      i += group.columnIds.length
    } else {
      cells.push({ type: 'column', col })
      i++
    }
  }
  return cells
})

sticky header と sticky 列

ヘッダー行は position: sticky; top: 0 で上端に固定。「区分」と「勘定科目」の2列は position: sticky; left: 0 / left: 2.5rem で左端に固定。交差部分(ヘッダー行の固定列)は z-index: 3 で最前面にする。

.worksheet-table th {
  position: sticky;
  top: 0;
  z-index: 2;
}

.col-section {
  position: sticky;
  left: 0;
  z-index: 1;
  background: #fff;
}

.col-name {
  position: sticky;
  left: 2.5rem;
  z-index: 1;
  background: #fff;
}

/* ヘッダー行の固定列は最前面 */
.worksheet-table th.col-section,
.worksheet-table th.col-name {
  z-index: 3;
}

2行ヘッダーの場合、2行目の top をずらす必要がある。

.header-row-2 th {
  position: sticky;
  top: 1.7rem; /* 1行目のヘッダーの高さ分 */
  z-index: 2;
}

個別財務諸表の勘定式レイアウト

IndividualFsTable.vue では、B/Sを勘定式(左: 資産、右: 負債・純資産)で表示する。

B/S行を「資産合計」の行で分割し、左右に配置する。

const bsAssetRows = computed(() => {
  const rows: IndividualFsRow[] = []
  for (const row of bsRows.value) {
    rows.push(row)
    if (row.label === '資産合計') break
  }
  return rows
})

const bsLiabilityRows = computed(() => {
  const rows = bsRows.value
  const startIdx = rows.findIndex(r => r.label === '資産合計') + 1
  return rows.slice(startIdx)
})

P/LとS/Sは横並びで表示する。

.bs-tformat {
  display: inline-flex;
  border: 1px solid #d1d5db;
  border-top: none;
}
.bs-side:first-child {
  border-right: 2px solid #9ca3af;
}
.pl-ss-grid {
  display: inline-flex;
  gap: 0.75rem;
  align-items: flex-start;
}

数値をクリックすると、連結ワークシートの該当会社カラムにジャンプする。

function handleValueClick() {
  emit('navigate-to-worksheet', props.company.id)
}

連結財務諸表の専用コンポーネント

ConsolidatedFsTable.vueIndividualFsTable.vue と同じ勘定式レイアウトだが、データ元が AccountRowvalues から取得する点が異なる。連結B/Sは fs-bs、連結P/Lは fs-pl、連結S/Sは fs-ss のカラムIDで値を参照する。

連結修正仕訳カード

JournalEntryCard

各仕訳は借方・貸方のテーブルとして表示する。合計行は仕訳が複数行の場合のみ表示。貸借不一致があればエラーバーを表示する。

<tr v-if="ev.maxLines > 1" class="entry-total-row">
  <td class="td-account debit total-label">合計</td>
  <td class="td-amount debit total-amount">{{ formatNumber(ev.debitTotal) }}</td>
  <td class="td-account credit total-label">合計</td>
  <td class="td-amount credit total-amount">{{ formatNumber(ev.creditTotal) }}</td>
</tr>

JournalCategoryTable

当初は仕訳ごとに個別のカードを並べていたが、全仕訳を1テーブルに統合した方が一覧性が高い。<colgroup> でカラム幅を固定し、借方・貸方の幅がずれないようにした。

<colgroup>
  <col class="col-debit-account" />   <!-- 24% -->
  <col class="col-debit-amount" />    <!-- 17% -->
  <col class="col-credit-account" />  <!-- 24% -->
  <col class="col-credit-amount" />   <!-- 17% -->
  <col class="col-link" />            <!-- 18% -->
</colgroup>

仕訳グループ(開始仕訳 / 当期仕訳)の区切りには、ヘッダー行を挿入する。

リンク先カラム

各仕訳の右端に「リンク先」カラムを設け、その仕訳が影響するワークシートのシート名・カラム名を表示する。クリックでジャンプできる。

2年目の開始仕訳では「1年目の当期仕訳」へのバックリンクも表示する。

interface JournalEntry {
  // ...
  sourceTopicLink?: { topicId: string; categoryId: string }
}

前提条件ページ

SVG資本関係図

P社->A社の資本関係をSVGで描画する。P社(親会社)ボックス、矢印、80%保有のラベル、A社(子会社)ボックス、NCI 20%のラベルを配置。

<svg viewBox="0 0 400 220">
  <rect x="130" y="10" width="140" height="50" rx="6" class="box-parent" />
  <text x="200" y="40" text-anchor="middle">P社(親会社)</text>
  <line x1="200" y1="60" x2="200" y2="130" class="arrow-line" />
  <polygon points="194,125 200,138 206,125" class="arrow-head" />
  <rect x="210" y="80" width="80" height="24" rx="4" class="ratio-bg" />
  <text x="250" y="97" text-anchor="middle">80% 保有</text>
  <rect x="130" y="140" width="140" height="50" rx="6" class="box-sub" />
  <text x="200" y="170" text-anchor="middle">A社(子会社)</text>
</svg>

Year 1 / Year 2 の切替

topicId の値に応じて前提条件の数値を切り替える。のれんの累計償却額、内部売上高、未実現利益の金額などが変わる。

2年目データの追加

開始仕訳と当期仕訳の分離

2年目の連結修正仕訳は「開始仕訳」と「当期仕訳」に分かれる。前期の連結修正仕訳を利益剰余金期首残高で再記入するのが開始仕訳である。

ワークシートのカラム構成:

開始仕訳: aje-open-capital, aje-open-goodwill, aje-open-unrealized, aje-open-total, aje-open-adjusted
当期仕訳: aje-capital, aje-goodwill, aje-interco, aje-unrealized, aje-current-total
合計:     aje-total

ヘッダーグループで「連結修正 - 開始仕訳」「連結修正 - 当期仕訳」としてまとめる。

現金預金のBS等式逆算

2年目のP社の現金預金はB/S等式(資産合計 = 負債純資産合計)から逆算した。他の勘定科目を先に決め、現金預金を差額で算出する方式である。テストで貸借一致を検証しているため、逆算の正しさを確認できる。

矢印キーナビゲーション

カテゴリ横断の移動

globalIndex で全カテゴリの項目を直列に並べ、左右矢印キーで順番に移動する。

const globalIndex = computed<GlobalItem[]>(() => {
  const ds = currentDataset.value
  return [
    { midCategory: 'prerequisites', smallId: 'prerequisites' },
    ...ds.individualCompanies.map(c => ({
      midCategory: 'individual-fs', smallId: c.id
    })),
    ...ds.journalCategories.map(c => ({
      midCategory: 'journal', smallId: c.id
    })),
    ...ds.sheets.map(s => ({
      midCategory: 'worksheet', smallId: s.id
    })),
  ]
})

Alt+矢印のブラウザバック保護

Alt+左矢印はブラウザの「戻る」操作。ナビゲーションのキーハンドラでは修飾キー付きのイベントを無視する。

function handleKeydown(e: KeyboardEvent) {
  if (e.altKey || e.ctrlKey || e.metaKey) return
  const el = document.activeElement
  if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
  if (e.key === 'ArrowLeft') { e.preventDefault(); goPrev() }
  else if (e.key === 'ArrowRight') { e.preventDefault(); goNext() }
}

クロスリンク

P社・A社数値 -> 個別FSへのジャンプ

ワークシートの「P社」「A社」カラムヘッダーに companyLink を設定。数値クリックで navigate-company イベントを発火し、個別財務諸表画面に遷移する。

連結修正仕訳カラム -> 仕訳カテゴリへのリンク

ワークシートの各連結修正仕訳カラムに journalLink を設定。カラムヘッダーのクリックで対応する仕訳カテゴリに遷移する。

仕訳 -> ワークシートへのリンク

仕訳テーブルのクリックで navigate イベントを発火。targetSheettargetColumn で遷移先を指定し、highlightColumn でカラムを黄色ハイライトする。

テストコード

consolidated-worksheet.test.ts で以下を検証する。

1. 水平整合性

各シート間で「スタート値 = 前シートのゴール値」を検証。

const linkedPairs = [
  { from: 'simple-total', to: 'ind-start', label: '単純合算 -> 個別修正' },
  { from: 'ind-total', to: 'aje-start', label: '個別修正 -> 連結修正仕訳' },
  { from: 'aje-total', to: 'rge-start', label: '連結修正仕訳 -> 組替仕訳' },
  { from: 'rge-total', to: 'fs-start', label: '組替仕訳 -> 連結財務諸表' },
]

2. 貸借一致

  • 全カラムで資産合計 = 負債純資産合計
  • 各調整列のB/S貸借バランス
  • 個別F/Sの資産合計 = 負債純資産合計
  • 仕訳の借方合計 = 貸方合計

3. B/S・S/S・P/Lの整合性

// S/S -> B/S連携
expect(bsRetained!.value).toBe(ssEndRetained!.value)

// P/L -> S/S連携
expect(plNetIncome!.value).toBe(ssNetIncome!.value)

4. 期間連続性(1年目期末 -> 2年目期首)

2年目のテストでは、1年目の期末利益剰余金が2年目の開始仕訳修正後の期首利益剰余金と一致することを検証する。

it('連結 利益剰余金期末残高(1年目) = 利益剰余金期首残高(2年目・開始仕訳修正後)', () => {
  const y1EndRetained = accountRows.find(r => r.name === '利益剰余金期末残高')!
  const y1EndValue = y1EndRetained.values['aje-total']
  const y2BeginRetained = accountRowsY2.find(r => r.name === '利益剰余金期首残高')!
  const y2BeginValue = y2BeginRetained.values['aje-open-adjusted']
  expect(y2BeginValue).toBe(y1EndValue)
})

カテゴリ別にも検証する。例えば、1年目ののれん償却がP/Lに与えた影響(当期純利益への効果)が、2年目の開始仕訳の利益剰余金期首残高と一致するか。

it('のれん: 1年目の当期P/L NI効果 = 2年目開始仕訳の利益剰余金期首残高', () => {
  const y1NI = calcNI(accountRows, 'aje-goodwill')
  const y2OpenBegin = accountRowsY2.find(r => r.name === '利益剰余金期首残高')!
  expect(y2OpenBegin.values['aje-open-goodwill']).toBe(y1NI)
})

5. 2年目のバグ修正

2年目のデータ作成では利益剰余金の整合性確保に苦労した。前期のP/L効果を利益剰余金期首残高に吸収するのが開始仕訳の役割であり、開始仕訳にP/L科目は入らない。これを検証するテスト:

const openColumnsNiZero = [
  'aje-open-capital', 'aje-open-goodwill',
  'aje-open-unrealized', 'aje-open-total'
]
for (const colId of openColumnsNiZero) {
  it(`${colId}: P/L科目から計算される当期純利益 = 0`, () => {
    const ni = calcNI(accountRowsY2, colId)
    expect(ni).toBe(0)
  })
}

連結精算表(全体)Sumシート

全シートを1つの巨大テーブルにまとめた「Sumシート」を作成した。ズーム付きコンテナ(ZoomableTableContainer)で75%表示し、全体を俯瞰できる。

<template v-else-if="currentSheet.id === 'worksheet-sum'">
  <ZoomableTableContainer :initial-zoom="75">
    <template #default>
      <WorksheetTable :sheet="currentSheet" :rows="currentDataset.accountRows" />
    </template>
    <template #magnified>
      <WorksheetTable :sheet="currentSheet" :rows="currentDataset.accountRows" />
    </template>
  </ZoomableTableContainer>
</template>

ズームコントロールはスライダーとボタンで操作できる。ResizeObserver でコンテンツサイズを計測し、ズーム倍率を掛けたサイズでラッパーを描画する。

型定義

データモデルは types.ts に集約した。

interface WorksheetDataset {
  sheets: WorksheetSheet[]
  journalCategories: JournalCategory[]
  accountRows: AccountRow[]
  individualCompanies: IndividualCompany[]
  findSheet: (id: string) => WorksheetSheet | undefined
  findJournalEntry: (id: string) => JournalEntry | undefined
}

各データセット(Year 1、Year 2)がこのインターフェースを実装し、dataset-registry.tsMap に登録する。ページ側は getDataset(topicId) で取得するだけ。

計画と実装の差分

実装計画から変更した点を整理する。

カラム構成: 3カラム → 4カラム

計画では「大カテゴリ / 中カテゴリ / コンテンツ」の3カラムを想定していた。実装では「論点 / 中カテゴリ / 小カテゴリ / コンテンツ」の4カラムに拡張した。ワークシート内の各シートや仕訳カテゴリ内の各カテゴリを選択する階層が増えたため、3カラムでは収まらなかった。

大カテゴリの再設計

計画では大カテゴリを「仕訳」「連結ワークシート」の2つとしていた。実装では「Year 1」「Year 2」をトップレベルの論点として切り出し、中カテゴリに「前提条件」「個別財務諸表」「連結修正仕訳」「連結精算表」を配置した。年度切替が最上位にあるほうが、1年目と2年目の比較に便利だと判断した。

前提条件・個別財務諸表の追加

計画にはなかったが、実装では「前提条件」(SVG資本関係図、基本前提テーブル)と「個別財務諸表」(P社・A社の勘定式B/S、P/L、S/S)を中カテゴリとして追加した。連結精算表のスタート地点となる個別F/Sデータを同じビューアー内で確認できるほうが利便性が高い。

連結財務諸表の表示方式

計画ではBS/PL/SS/CFを小カテゴリとして分割する想定だった。実装ではConsolidatedFsTableコンポーネントで勘定式レイアウト(B/S左右分割、P/L・S/S横並び)をまとめて表示する方式にした。分割するより一画面で見渡せるほうが連結F/S全体の構造を把握しやすい。CF(キャッシュ・フロー計算書)は、精算表の延長線上で作成する書類ではなく作成プロセスが異なるため対象外とした。

虫眼鏡機能 → ズームスライダー

計画では「マウスホバーで該当セル周辺を2〜3倍に拡大表示」する虫眼鏡を想定していた。実装ではZoomableTableContainerコンポーネントでスライダーによるズーム操作に変更した。虫眼鏡はhover位置の追従やposition: fixedの制御が複雑になるため、シンプルなズームスライダーで十分と判断した。

URLクエリパラメータの拡張

計画では ?sheet=xxx&sub=xxx の2パラメータだった。実装では4カラム構成に合わせて ?topic=xxx&mid=xxx&sheet=xxx&journal=xxx&company=xxx&highlight=xxx に拡張した。カラムハイライト状態もURLに保存するため、リンク共有時に仕訳からのジャンプ先カラムまで再現できる。

ふりかえり

  • Miller Columns の4カラム構成は、連結精算表の階層構造と相性がよい。左のカラムで「どの論点のどの表を見ているか」が常に視界に入る
  • 矢印キーでカテゴリ横断の移動ができるので、分割表示のストレスはかなり軽減できた
  • 貸借一致と水平整合性をテストで担保しておくと、データ修正時の安心感が違う。特に数値の整合性が複雑な2年目の開始仕訳は、テストなしではやっていられない
  • sticky headerとsticky列の組み合わせは、z-indexの管理が面倒だが、連結精算表のような横長テーブルでは必須
  • 連結財務諸表の勘定式レイアウト(B/Sの左右分割、P/L・S/Sの横並び)は、個別F/Sも連結F/Sも同じパターンで実装できた