document_typesテーブルのスキーマリファクタリング
tax-assistantプロジェクトのdocument_typesテーブルについて、カラム名の意味を明確にし、ソート方式を改善するリファクタリングを実施した。
背景と課題
document_typesテーブルは帳票種別(レシート、売上伝票など)を管理するマスタテーブルとして機能している。当初の設計では以下の問題があった。
1. カラム名の曖昧さ
-- Before: 何を表すか分かりにくい
CREATE TABLE document_types (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE, -- 何のコード?
name TEXT NOT NULL, -- 内部名?表示名?
display_order INTEGER DEFAULT 0 -- 手動管理が面倒
);
code: 「コード」という汎用的な名前で、用途が不明瞭name: 内部識別子なのか表示用ラベルなのか分からないdisplay_order: 手動での順序管理が必要で、追加・削除時に再調整が必要
2. 仕訳ルールタブとの連携問題
仕訳ルール画面(JournalRulesTab.vue)ではURLクエリパラメータdocTypeで帳票種別を指定していたが、以下の問題があった。
// Before: パラメータ名とカラム名の不一致
const route = useRoute()
const docType = route.query.docType as string // 'receipt' など
// document_typesテーブルのcodeと照合
const type = documentTypes.find(t => t.code === docType)
docTypeという名前がcodeカラムを指すことが分かりにくい- 他の開発者が見たときに対応関係が不明
実装内容
1. カラム名の変更
SQLiteのスキーマを変更し、カラム名を明確にした。
-- After: 用途が明確
CREATE TABLE document_types (
id INTEGER PRIMARY KEY,
doc_category TEXT NOT NULL UNIQUE, -- 帳票カテゴリ(識別子)
display_name TEXT NOT NULL, -- 表示用ラベル
created_at TEXT DEFAULT (datetime('now'))
);
| 変更前 | 変更後 | 理由 |
|---|---|---|
code | doc_category | 「帳票カテゴリ」という用途が明確に |
name | display_name | 表示用であることが明確に |
display_order | 削除 | created_atでソートに変更 |
2. マイグレーションスクリプト
既存データを保持しながらスキーマを変更するマイグレーションを作成した。
-- migrations/005_rename_document_types_columns.sql
-- 1. 一時テーブルを作成(新しいスキーマ)
CREATE TABLE document_types_new (
id INTEGER PRIMARY KEY,
doc_category TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
-- 2. データを移行(display_orderの順序でcreated_atを設定)
INSERT INTO document_types_new (id, doc_category, display_name, created_at)
SELECT
id,
code,
name,
datetime('2026-01-01 00:00:00', '+' || display_order || ' seconds')
FROM document_types
ORDER BY display_order;
-- 3. 旧テーブルを削除
DROP TABLE document_types;
-- 4. 新テーブルをリネーム
ALTER TABLE document_types_new RENAME TO document_types;
-- 5. インデックスを再作成
CREATE UNIQUE INDEX idx_document_types_category ON document_types(doc_category);
ポイント:
display_orderの値を秒数としてcreated_atに反映し、既存の並び順を維持UNIQUE制約とインデックスをdoc_categoryに設定
3. TypeScript型定義の更新
バックエンドとフロントエンドの型定義を更新した。
// types/document-type.ts
// Before
interface DocumentType {
id: number
code: string
name: string
display_order: number
}
// After
interface DocumentType {
id: number
doc_category: string // 'receipt' | 'sales_slip' | ...
display_name: string // 'レシート' | '売上伝票' | ...
created_at: string
}
4. APIエンドポイントの更新
GET /api/document-typesのレスポンス形式を更新した。
// server/api/document-types.get.ts
export default defineEventHandler(async (event) => {
const db = getDb()
// Before
const types = db.prepare(`
SELECT id, code, name, display_order
FROM document_types
ORDER BY display_order
`).all()
// After
const types = db.prepare(`
SELECT id, doc_category, display_name, created_at
FROM document_types
ORDER BY created_at
`).all()
return types
})
5. 仕訳ルールタブとの連携改善
URLクエリパラメータをdocTypeからdocTypeIdに変更し、ID参照方式に統一した。
// composables/useJournalRules.ts
// Before: codeで検索(曖昧)
const route = useRoute()
const docType = route.query.docType as string
const currentType = documentTypes.value.find(t => t.code === docType)
// After: IDで検索(明確)
const docTypeId = Number(route.query.docTypeId)
const currentType = documentTypes.value.find(t => t.id === docTypeId)
<!-- JournalRulesTab.vue -->
<!-- Before -->
<router-link
v-for="type in documentTypes"
:key="type.id"
:to="{ query: { docType: type.code } }"
>
{{ type.name }}
</router-link>
<!-- After -->
<router-link
v-for="type in documentTypes"
:key="type.id"
:to="{ query: { docTypeId: type.id } }"
>
{{ type.display_name }}
</router-link>
6. 後方互換性の実装
既存のブックマークやリンクが壊れないよう、古いパラメータ形式をリダイレクトする処理を追加した。
// middleware/legacy-params.ts
export default defineNuxtRouteMiddleware((to, from) => {
// 古い docType パラメータが使われている場合
if (to.query.docType && !to.query.docTypeId) {
const docType = to.query.docType as string
// doc_category から id を解決
const typeMap: Record<string, number> = {
'receipt': 1,
'sales_slip': 2,
// 必要に応じて追加
}
const docTypeId = typeMap[docType]
if (docTypeId) {
// 新しいパラメータ形式にリダイレクト
const newQuery = { ...to.query, docTypeId }
delete (newQuery as Record<string, unknown>).docType
return navigateTo({
path: to.path,
query: newQuery
}, { replace: true })
}
}
})
ミドルウェアの適用:
// pages/journal-rules.vue
definePageMeta({
middleware: ['legacy-params']
})
Codexレビューの活用
今回のリファクタリングでは、Codex CLI(GPT-5.2)によるレビューを2回実施した。
第1回レビュー: スキーマ設計
マイグレーションスクリプトのドラフトをレビュー依頼した。
指摘事項:
| 指摘 | 対応 |
|---|---|
created_atのデフォルト値で順序を維持する方法が不明瞭 | display_orderを秒数として加算する方式を採用 |
| トランザクション境界がない | BEGIN/COMMITで囲むよう修正 |
| ロールバック手順がない | バックアップテーブル作成のオプションを追加 |
修正後のマイグレーション:
-- 安全なマイグレーション(トランザクション付き)
BEGIN TRANSACTION;
-- バックアップ(ロールバック用)
CREATE TABLE document_types_backup AS SELECT * FROM document_types;
-- ... マイグレーション処理 ...
COMMIT;
-- 問題があれば: DROP TABLE document_types; ALTER TABLE document_types_backup RENAME TO document_types;
第2回レビュー: 後方互換性
ミドルウェアによるリダイレクト処理をレビュー依頼した。
指摘事項:
| 指摘 | 対応 |
|---|---|
ハードコードされたtypeMapはメンテナンスが困難 | APIから動的に取得する方式を検討(今回は見送り) |
| リダイレクトループの可能性 | docTypeIdが既に存在する場合はスキップする条件を追加 |
| ログ出力がない | 開発環境でのみ警告ログを出力するよう追加 |
// 改善版ミドルウェア
export default defineNuxtRouteMiddleware((to, from) => {
if (to.query.docType && !to.query.docTypeId) {
// 開発環境でのみ警告
if (import.meta.dev) {
console.warn(
`[legacy-params] Deprecated parameter 'docType' used. ` +
`Please use 'docTypeId' instead.`
)
}
// ... リダイレクト処理 ...
}
})
e2eテストの追加
Playwrightを使用したe2eテストを追加し、リファクタリング後の動作を検証した。
テストケース
// tests/e2e/journal-rules.spec.ts
import { test, expect } from '@playwright/test'
test.describe('仕訳ルールタブ', () => {
test('帳票種別の切り替えが正常に動作する', async ({ page }) => {
await page.goto('/journal-rules')
// デフォルトで最初の帳票種別が選択されている
const firstTab = page.locator('[data-testid="doc-type-tab"]').first()
await expect(firstTab).toHaveClass(/active/)
// 2番目のタブをクリック
const secondTab = page.locator('[data-testid="doc-type-tab"]').nth(1)
await secondTab.click()
// URLパラメータが更新される
await expect(page).toHaveURL(/docTypeId=2/)
// タブのアクティブ状態が切り替わる
await expect(secondTab).toHaveClass(/active/)
await expect(firstTab).not.toHaveClass(/active/)
})
test('後方互換性: 旧パラメータがリダイレクトされる', async ({ page }) => {
// 旧パラメータ形式でアクセス
await page.goto('/journal-rules?docType=receipt')
// 新パラメータ形式にリダイレクトされる
await expect(page).toHaveURL(/docTypeId=1/)
await expect(page).not.toHaveURL(/docType=/)
// 正しい帳票種別が選択されている
const receiptTab = page.locator('[data-testid="doc-type-tab"]').first()
await expect(receiptTab).toHaveClass(/active/)
})
test('存在しない帳票種別IDでエラーにならない', async ({ page }) => {
await page.goto('/journal-rules?docTypeId=999')
// エラーページにならない
await expect(page.locator('h1')).not.toContainText('Error')
// フォールバックで最初の帳票種別が選択される
const firstTab = page.locator('[data-testid="doc-type-tab"]').first()
await expect(firstTab).toHaveClass(/active/)
})
})
テストデータのセットアップ
// tests/e2e/fixtures/document-types.ts
export const testDocumentTypes = [
{ id: 1, doc_category: 'receipt', display_name: 'レシート' },
{ id: 2, doc_category: 'sales_slip', display_name: '売上伝票' },
]
// tests/e2e/global-setup.ts
import { testDocumentTypes } from './fixtures/document-types'
export default async function globalSetup() {
// テスト用DBにシードデータを投入
const db = getTestDb()
db.exec('DELETE FROM document_types')
const stmt = db.prepare(`
INSERT INTO document_types (id, doc_category, display_name, created_at)
VALUES (?, ?, ?, datetime('now'))
`)
for (const type of testDocumentTypes) {
stmt.run(type.id, type.doc_category, type.display_name)
}
}
CI統合
GitHub Actionsでe2eテストを実行するワークフローを追加した。
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
paths:
- 'apps/web/**'
- 'tests/e2e/**'
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
フロントエンドの修正箇所
以下のファイルでカラム名の参照を更新した。
コンポーネント
// BatchDocumentTypeList.vue
interface Props {
documentTypes: DocumentType[]
selectedType: string // doc_category を受け取る
}
// 表示名の参照
<li v-for="type in documentTypes" :key="type.id">
{{ type.display_name }} <!-- name → display_name -->
</li>
Composables
// composables/useDocumentTypes.ts
export const useDocumentTypes = () => {
const documentTypes = ref<DocumentType[]>([])
const fetchTypes = async () => {
const response = await $fetch('/api/document-types')
documentTypes.value = response
}
// カテゴリからIDを取得
const getIdByCategory = (category: string): number | undefined => {
return documentTypes.value.find(t => t.doc_category === category)?.id
}
// IDから表示名を取得
const getDisplayName = (id: number): string => {
return documentTypes.value.find(t => t.id === id)?.display_name ?? ''
}
return {
documentTypes,
fetchTypes,
getIdByCategory,
getDisplayName
}
}
ソート方式の変更
display_orderを削除し、created_atによるソートに変更した理由と実装。
理由
- メンテナンスコストの削減: 新しい帳票種別を追加する際、既存の
display_orderを調整する必要がなくなった - 自然な順序: 登録順=表示順という直感的な並び
- 競合の回避: 複数人で同時に追加しても順序が衝突しない
実装
-- 新しい帳票種別の追加(display_orderの指定不要)
INSERT INTO document_types (doc_category, display_name)
VALUES ('invoice', '請求書');
-- created_atは自動的に現在時刻が設定される
// 取得時のソート
const types = db.prepare(`
SELECT id, doc_category, display_name, created_at
FROM document_types
ORDER BY created_at ASC
`).all()
カスタム順序が必要な場合
将来的にカスタム順序が必要になった場合は、以下のアプローチを検討する。
// フロントエンドでの並べ替え
const sortOrder = ['receipt', 'sales_slip', 'invoice', 'expense']
const sortedTypes = computed(() => {
return [...documentTypes.value].sort((a, b) => {
const indexA = sortOrder.indexOf(a.doc_category)
const indexB = sortOrder.indexOf(b.doc_category)
return indexA - indexB
})
})
まとめ
今回のリファクタリングで、document_typesテーブルの設計が改善され、コードの可読性とメンテナンス性が向上した。
改善点:
| 項目 | Before | After |
|---|---|---|
| カラム名 | code, name | doc_category, display_name |
| ソート方式 | display_order(手動管理) | created_at(自動) |
| URLパラメータ | docType(文字列) | docTypeId(ID) |
| 後方互換性 | なし | ミドルウェアでリダイレクト |
| テスト | なし | e2eテストを追加 |
Codexレビューで得られた知見:
- マイグレーションにはトランザクション境界を明示する
- ロールバック手順を用意しておく
- リダイレクトループの防止条件を入れる
- 開発環境でのみ警告ログを出力する
今後の展望:
- 帳票種別の追加UIの実装
- 帳票種別ごとのアイコン設定
- 帳票種別の論理削除対応