• #Vue.js
  • #FastAPI
  • #SQLite
  • #OCR
  • #税務
開発tax-assistantメモ

手動売上伝票登録機能の実装

背景と課題

税務アシスタントアプリでは、Square決済明細と売上伝票を突き合わせてチェックする機能を提供している。しかし、以下のケースで問題が発生する。

  • 紙の領収書がスキャンできなかった(破損、紛失)
  • Webの領収書(PDFダウンロード)でOCR対象外
  • 手書き伝票の文字が不鮮明でOCR失敗

Square明細は確実なデータとして存在するが、対応する売上伝票がDBに存在しないため「不一致」として検出されてしまう。

解決策:manualバッチの導入

設計思想

通常の売上伝票はOCRバッチ(例:20260109_144208)単位で管理される。手動登録用に特別なバッチ「manual」を用意し、以下の役割分担を実現する。

操作場所バッチID
新規登録読取一覧タブ > 📝 手動登録manual_register(仮想)
閲覧・編集読取一覧タブ > manual バッチmanual
突合チェックSquare明細タブ-

ポイントは「登録」と「閲覧・編集」のUIを分離したこと。Square明細タブで安直に伝票を追加するボタンを設けると、本来OCRで読み取るべきものまで手動登録されてしまうリスクがある。

バッチ一覧の表示

バッチ一覧
├── 📝 手動登録        ← クリックで新規登録フォーム表示
├── manual  2/2        ← 登録済みデータの閲覧・編集
├── 20260120_144802  5/5
├── 20260109_144208  65/65
└── ...

実装詳細

1. DBヘルパー関数(db.py)

手動登録用バッチを取得または作成する関数を追加。

def get_or_create_manual_batch() -> str:
    """手動登録用のバッチIDを取得または作成する"""
    batch_id = "manual"

    conn = get_db_connection()
    try:
        # バッチが存在するかチェック
        existing = conn.execute(
            "SELECT batch_id FROM batches WHERE batch_id = ?",
            (batch_id,)
        ).fetchone()

        if not existing:
            # 新規作成
            conn.execute(
                """INSERT INTO batches (batch_id, created_at, document_type)
                   VALUES (?, datetime('now'), 'sales_slip')""",
                (batch_id,)
            )
            conn.commit()

        return batch_id
    finally:
        conn.close()

2. 手動売上伝票の追加API(ocr_server.py)

@app.post("/api/receipts/manual")
async def add_manual_sales_slip(data: ManualSalesSlipRequest):
    """手動で売上伝票を追加する"""
    batch_id = get_or_create_manual_batch()

    # ユニークなfile_nameを生成(DB制約対応)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    count = get_manual_count_for_today()
    file_name = f"manual_{timestamp}_{count + 1}"

    receipt_data = {
        "file_name": file_name,
        "batch_id": batch_id,
        "date": data.date,
        "amount": data.amount,
        "vendor": data.vendor,
        "account_category": data.account_category,
        "description": data.description,
        "confirmed": True,  # 手動登録は即確定
        "document_type": "sales_slip"
    }

    insert_receipt(receipt_data)
    return {"success": True, "file_name": file_name, "batch_id": batch_id}

3. フロントエンド:BatchListの拡張

売上伝票選択時のみ「📝 手動登録」エントリを表示。

<!-- BatchList.vue -->
<template>
  <div class="batch-list">
    <!-- 手動登録エントリ(売上伝票の場合のみ) -->
    <div
      v-if="showManualRegister"
      class="batch-item manual-register"
      :class="{ active: isManualRegisterSelected }"
      @click="$emit('select', 'manual_register')"
    >
      <span class="batch-name">📝 手動登録</span>
    </div>

    <!-- 通常のバッチ一覧 -->
    <div
      v-for="batch in batches"
      :key="batch.batch_id"
      class="batch-item"
      :class="{
        active: batch.batch_id === currentBatchId,
        'manual-batch': isManualBatch(batch.batch_id)
      }"
      @click="$emit('select', batch.batch_id)"
    >
      <span class="batch-name">{{ formatBatchName(batch) }}</span>
      <span class="batch-count">{{ batch.confirmed_count }}/{{ batch.total_count }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
const isManualBatch = (batchId: string) => {
  return batchId === 'manual' || batchId.startsWith('manual_')
}

const formatBatchName = (batch: Batch) => {
  if (isManualBatch(batch.batch_id)) {
    return 'manual'
  }
  // 通常バッチは日付フォーマット
  return formatBatchDate(batch.batch_id)
}
</script>

4. ReceiptFormの手動モード対応

手動登録バッチ選択時、アイテムがない場合は新規登録フォームを表示。

<!-- ReceiptForm.vue -->
<template>
  <div class="receipt-form">
    <!-- 通常モード:既存データの表示・編集 -->
    <template v-if="item">
      <div class="form-row">
        <label>日付</label>
        <input type="date" v-model="formData.date" :disabled="!isEditing" />
      </div>
      <!-- ... 他のフィールド ... -->
    </template>

    <!-- 手動モード:新規登録フォーム -->
    <template v-else-if="isManualMode">
      <div class="manual-form">
        <h3>新規売上伝票登録</h3>

        <div class="form-row">
          <label>日付 *</label>
          <input type="date" v-model="manualFormData.date" required />
        </div>

        <div class="form-row">
          <label>金額 *</label>
          <input
            type="number"
            v-model="manualFormData.amount"
            placeholder="例: 5500"
            required
          />
        </div>

        <div class="form-row">
          <label>勘定科目</label>
          <select v-model="manualFormData.account_category">
            <option value="">選択してください</option>
            <option value="売上高">売上高</option>
            <option value="会議費">会議費</option>
            <!-- ... -->
          </select>
        </div>

        <div class="form-row">
          <label>取引先</label>
          <input
            type="text"
            v-model="manualFormData.vendor"
            placeholder="例: 株式会社ABC"
          />
        </div>

        <div class="form-row">
          <label>摘要</label>
          <textarea
            v-model="manualFormData.description"
            placeholder="例: 打ち合わせ飲食代"
          ></textarea>
        </div>

        <div class="form-actions">
          <button
            class="btn-primary"
            @click="handleManualSubmit"
            :disabled="!isManualFormValid"
          >
            登録
          </button>
        </div>
      </div>
    </template>

    <!-- データなし -->
    <template v-else>
      <div class="no-data">データがありません</div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { api } from '@/api'

const props = defineProps<{
  item: Receipt | null
  isManualMode: boolean
}>()

const emit = defineEmits<{
  manualRegistered: [receipt: Receipt]
}>()

const manualFormData = ref({
  date: '',
  amount: null as number | null,
  account_category: '',
  vendor: '',
  description: ''
})

const isManualFormValid = computed(() => {
  return manualFormData.value.date && manualFormData.value.amount
})

const handleManualSubmit = async () => {
  if (!isManualFormValid.value) return

  const result = await api.addManualSalesSlip({
    date: manualFormData.value.date,
    amount: manualFormData.value.amount!,
    account_category: manualFormData.value.account_category,
    vendor: manualFormData.value.vendor,
    description: manualFormData.value.description
  })

  if (result.success) {
    emit('manualRegistered', result.receipt)
    // フォームをリセット
    manualFormData.value = {
      date: '',
      amount: null,
      account_category: '',
      vendor: '',
      description: ''
    }
  }
}
</script>

5. useReceiptsの拡張:selectedBatchIdの追跡

手動バッチにアイテムがない場合でも選択状態を保持するため、明示的なselectedBatchIdを追加。

// useReceipts.ts
export function useReceipts() {
  const allItems = ref<Receipt[]>([])
  const currentIndex = ref(0)
  const selectedBatchId = ref<string | null>(null)  // 明示的に選択されたバッチID

  const currentItem = computed(() => allItems.value[currentIndex.value] ?? null)

  // currentBatchIdはselectedBatchIdを優先
  const currentBatchId = computed(() => {
    return selectedBatchId.value ?? currentItem.value?.batch_id ?? null
  })

  const selectBatch = async (batchId: string) => {
    selectedBatchId.value = batchId  // 明示的に設定

    // バッチのアイテムを読み込み
    await loadAllReceipts({
      document_type: currentDocType.value,
      batch_id: batchId
    })

    // 最初のアイテムを選択
    if (allItems.value.length > 0) {
      currentIndex.value = 0
    }
  }

  return {
    allItems,
    currentItem,
    currentBatchId,
    selectedBatchId,
    selectBatch,
    // ...
  }
}

6. 登録後のフロー

手動登録が完了すると、以下の処理が実行される。

  1. APIが新しいレシートを返す
  2. manualRegisteredイベントが発火
  3. バッチ一覧を再読み込み
  4. manualバッチを選択
  5. 登録したレシートを表示
// index.vue
const handleManualRegistered = async (receipt: Receipt) => {
  // バッチ一覧を更新
  await loadBatches()

  // manualバッチを選択
  await selectBatch('manual')

  // 登録したレシートを選択
  selectByKey(receipt.batch_id, receipt.file_name)
}

UIの役割分担

Square明細タブ

  • 突合結果の閲覧専用ビュー
  • 不一致があっても「追加」ボタンは置かない
  • 「レシートとチェック」で自動突合

読取一覧タブ

バッチ役割
📝 手動登録新規登録フォーム表示
manual登録済みデータの閲覧・編集
OCRバッチスキャン済みデータの確認・修正

この分離により、「安直に手動登録してOCRをサボる」ことを抑止しつつ、本当に必要なケースでは登録できる設計となった。

ファイル名の扱い

手動登録ではスキャン画像がないため、擬似的なfile_nameを生成する。

manual_20260120_094120_1
│      │        │
│      │        └─ 連番
│      └─ タイムスタンプ
└─ プレフィックス

UIではmanual_で始まるfile_nameは非表示にして、ユーザーに混乱を与えないようにする。

<!-- ResultTable.vue -->
<td>{{ isManualFile(item.file_name) ? '' : item.file_name }}</td>

今後の拡張

  • 画像添付機能(ドラッグ&ドロップ)
  • 手動登録データの一括インポート(CSV)
  • 仕訳プレビューとの連携

参照

  • memo/2026-01-15/square-import-plan.md - Square明細インポート計画
  • memo/2026-01-20/index-vue-refactoring-plan.md - タブ分割リファクタリング計画