こんにちは。開発部の宮城です。
今回は、弊社のサービスである「飲食店ドットコム 店舗物件探し」において、物件登録に関わる業務を効率化するために開発した機能についてお話しします。
以前のブログ記事「生成AIを利用して物件登録にかかる時間を大幅削減した話」では、マイソク(物件情報をまとめた資料)の画像をアップロードし、OCRと生成AIで物件情報を自動抽出する機能について紹介しました。今回はその後続として、下書き機能とマイソクの一括アップロード機能の2つを紹介します。
物件登録補助機能の振り返り
まず、以前のブログ記事で紹介した物件登録補助機能を簡単に振り返ります。
物件の登録は入力項目が多く、不動産会社の担当者にとって手間と時間がかかる作業でした。そこで、マイソクをアップロードするだけで、Cloud Vision APIによるOCR処理とOpenAIのGPT-4oによる情報抽出を組み合わせ、フォームに自動入力する機能を開発しました。
この機能によって、半数以上の利用者が物件登録の際に3分以上の短縮を実感するなど、一定の成果を得ることができました。
この物件登録補助機能は不動産会社向けに提供したものですが、社内の事業部向け管理画面にも同様のマイソク読み取り機能を展開しました。当事業部では、不動産会社様とエンドユーザー様を繋ぐカスタマーサポートを行っており、パートナー制度で提携いただいている企業様に向けて、業務負荷の軽減や掲載スピード向上、成約の最大化を目的とした物件登録のサポート業務を担っています。
しかし、実際に機能を提供してみると、事業部特有の業務フローにはまだカバーしきれない課題があることが分かりました。今回は、その課題を解決するために追加で開発した2つの機能を紹介します。
下書き機能
業務的な背景
弊社の事業部では、物件を登録する際にダブルチェック(二重確認)を行う運用フローがあります。しかし、これまでは登録内容を一時保存する仕組みがなかったため、登録作業中は途中保存ができず、一度登録を完了する必要がありました。そのため、登録作業を段階的に進めることが難しく、作業の進め方に制約がありました。
そこで、登録フォームの入力内容を「下書き」として保存し、確認後に本登録できる機能を追加しました。
技術的に工夫したポイント
フォームオブジェクトの属性をJSONでまるごと保存する
下書き機能の実装において、最も特徴的なのは登録フォームの入力内容をJSON形式でそのままデータベースに保存するという設計です。
物件登録のフォームには50項目以上の入力フィールドがあります。これらの項目ごとに下書き用のカラムを用意するのは、スキーマの管理が煩雑になりますし、登録フォームに項目が追加されるたびに下書き用のテーブルも修正する必要が生じます。
そこで、Railsのフォームオブジェクトの属性をまるごとJSONとしてシリアライズし、1つのカラムに保存するアプローチを採りました。
def save_as_draft! draft = Draft.find_by(id: draft_id) || Draft.new # フォームオブジェクトの全属性をJSONに変換して保存 draft.register_form_json = JSON.parse(to_json) .except('error_attributes', 'draft_id') # 内部状態やIDは除外 draft.save! end
to_jsonでフォームオブジェクトの全属性をシリアライズし、exceptで不要な属性(バリデーション用の内部状態など)を除外しています。これにより、登録フォームに新しい項目が追加されても、下書きテーブルの変更は不要になっています。また、JSONカラムに保存することで、バリデーションをスキップした状態での保存が容易になるというメリットもあります。例えば、数値項目に日本語が入力されているような途中状態でも、そのまま下書きとして保存できます。
下書きから本登録へのライフサイクル
下書きから本登録を行う際には、物件の登録処理と下書きの削除を1つのトランザクション内で行います。
def save! ApplicationRecord.transaction do # ... 物件登録処理 ... # 下書きから登録した場合は下書きを削除する draft = Draft.find_by(id: draft_id) if draft.present? draft.bukken = bukken # 登録された物件との紐付けを保持 draft.save! draft.destroy! # 論理削除(acts_as_paranoid) end end end
下書き物件ではacts_as_paranoidによる論理削除を採用しているため、下書きから登録された物件の履歴を追跡することも可能になっています。
マイソクの一括アップロード機能
業務的な背景
パートナー制度の開始
2025年4月より、弊社では不動産会社向けの「パートナー制度」を開始しました。これは、不動産会社にて行っていただいていた物件登録作業を、弊社の事業部が無料で代行する制度です。
この制度の開始に伴い、事業部が一度に大量の物件を登録する必要が出てきました。以前のブログ記事で紹介した物件登録補助機能は単体のマイソクアップロードにしか対応しておらず、マイソクアップロード→読み取り待ち→入力確認を1件ずつ繰り返す必要がありました。数十件の物件を登録する場合、この繰り返しだけでもかなりの時間がかかります。
さらに、飲食店ドットコムでは取り扱う不動産会社が異なるだけの住所が重複した物件は掲載しない仕様になっています。そのため、マイソクをアップロードして住所を抽出し、重複バリデーションにかけた結果、重複物件だと判明して登録できないケースも多く、1件ずつ試しては次へ進むという非効率な作業が発生していました。
そこで、複数のマイソクを一括でアップロードし、まとめてOCRと生成AIによる読み取り処理を行い、下書き物件として一括登録できる機能を開発しました。
処理全体の流れ
一括アップロード機能の処理は、大きく3つのフェーズに分かれます。以下のシーケンス図で全体像を示します。
sequenceDiagram
actor User as ユーザー(ブラウザ)
participant Rails as Rails サーバー
participant S3 as Amazon S3
participant Sidekiq as Sidekiq Worker
participant Vision as Cloud Vision API
participant OpenAI as OpenAI API
rect rgb(232, 245, 253)
Note over User, S3: フェーズ1: アップロード
User ->> Rails: Presigned URL を取得
Rails -->> User: Presigned URL を返却
User ->> S3: マイソクを並列アップロード(最大20枚)
Note right of S3: 一時ディレクトリに保存
User ->> Rails: ファイルトークン + ファイル名を送信
end
rect rgb(232, 253, 235)
Note over Rails, OpenAI: フェーズ2: 読み取り処理(非同期)
Rails ->> Sidekiq: 一括読み取りジョブを起動
loop マイソクごとに直列処理
Sidekiq ->> S3: 一時ディレクトリ → 本番ディレクトリに移動
Sidekiq ->> Vision: OCR 処理
Vision -->> Sidekiq: テキストデータ
Sidekiq ->> OpenAI: 物件情報の抽出
OpenAI -->> Sidekiq: 抽出結果(JSON)
end
Sidekiq -->> Rails: 処理完了(ステータス更新)
end
rect rgb(253, 243, 226)
Note over User, Rails: フェーズ3: 下書き登録
User ->> Rails: 読み取り結果を確認
User ->> Rails: 選択したマイソクの一括下書き登録
Note right of Rails: フォームの属性を<br/>JSON として DB に保存
end
技術的に工夫したポイント
S3 Presigned URLによるダイレクトアップロード
最大20枚のマイソクを扱うため、すべてのファイルをRailsサーバー経由でアップロードすると、サーバーへの負荷が大きくなります。そこで、S3のPresigned URLを使い、ブラウザからS3に直接アップロードする方式を採用しました。
async function uploadAllFiles(files) { // 最初に1回だけ Presigned URL を取得 const presignedData = await getPresignedUrl(presignedUrlEndpoint) // 全ファイルを並列アップロード(同じPresigned URLを使用) const uploadPromises = files.map((file) => { if (file.size > MAX_FILE_SIZE) { return Promise.resolve({ success: false }) } return uploadToS3(presignedData, file) }) const results = await Promise.all(uploadPromises) uploadedFiles = results.filter((r) => r.success).map((r) => r.result) }
ポイントは以下の通りです。
- Presigned URLは1回だけ取得し、全ファイルで共有する。ファイルごとに
crypto.randomUUID()でユニークなキー(ファイルトークン)を生成することで、同じPresigned URLでも異なるS3キーにアップロードできるようにしている Promise.allによる並列アップロードで、20枚のマイソクを効率的にアップロードする- Railsサーバーにはファイル本体ではなく、ファイルトークン(S3キー)とファイル名だけを送信する
function uploadToS3(presignedData, file) { return new Promise((resolve, reject) => { const extension = file.name.includes('.') ? file.name.substring(file.name.lastIndexOf('.')) : '' const fileToken = self.crypto.randomUUID() + extension const formData = new FormData() for (const key in presignedData.formData) { if (key === 'key') { // Presigned URLのkeyテンプレートにファイルトークンを埋め込む formData.append(key, presignedData.formData[key].replace('${filename}', fileToken)) } else { formData.append(key, presignedData.formData[key]) } } formData.append('file', file) const xhr = new XMLHttpRequest() xhr.open('POST', presignedData.url) xhr.send(formData) xhr.onreadystatechange = function () { if (xhr.readyState === 4) { resolve({ file_token: fileToken, file_name: file.name }) } } }) }
これにより、ファイルのアップロード自体はRailsサーバーの負荷をほとんどかけずに完了します。
AI読み取り処理の直列実行
アップロード完了後、OCRと生成AIによる読み取り処理はSidekiqのバックグラウンドジョブとして実行します。
既存の単体マイソクアップロード機能でも同じAI読み取りワーカーを使用しており、このワーカーはSidekiq::Throttledで同時実行数が制限されています。一括アップロードで複数のマイソクを並列に読み取ろうとすると、この枠を占有してしまい、単体アップロード機能を同時に使っているユーザーが待たされてしまいます。
直列にすると処理時間が長くなる懸念がありますが、事前に検証したところ20枚でも2〜3分程度で完了し、Sidekiqのタイムアウトにも抵触しないことを確認できたため、一括アップロードジョブの中ではAI読み取り処理を直列で実行するようにしました。
class BulkUploadWorker include Sidekiq::Worker def perform(files, job_id) # マイソクごとにAI読み取り処理を直列で実行する files.each do |file_params| # 非同期ではなく同期的に呼び出すことで、同時実行枠を占有しない ExtractMaisokuWorker.new.perform(file_params['file_token']) end end end
1件の失敗が全体を止めない設計
一括処理で重要なのは、1件の失敗が残りの処理をすべて止めてしまわないことです。20件中1件だけOCRに失敗したために残り19件が処理されないのでは、一括処理の意味がなくなります。
def process_file(file_token, file_name) maisoku = create_maisoku_record(file_token, file_name) begin extract_property_info(maisoku) rescue StandardError => e maisoku.fail!(message: 'ファイルから項目が読み取れませんでした。') # 失敗しても次の処理を続行するために例外は投げない # ただし、エラー監視サービスを通じて開発チームに通知する notify_error(e, maisoku) end end
個々のマイソクの読み取り処理で例外が発生した場合は、そのマイソクを「失敗」状態にマークしつつ、次のファイルの処理に進みます。ユーザーには簡潔なエラーメッセージを表示し、詳細なエラー情報はエラー監視サービスを通じて開発チームに通知することで、問題に素早く気づける仕組みにしています。
下書き機能との連携
一括アップロードで読み取りが完了したマイソクは、前述の下書き機能と連携して一括で下書き物件として登録できます。
def draft_bulk_insert(maisoku_ids) maisokus = Maisoku.includes(:company).where(id: maisoku_ids) ApplicationRecord.transaction do maisokus.each do |maisoku| register_form = RegisterForm.new( company: maisoku.company, maisoku_id: maisoku.id, ) register_form.save_as_draft! end end end
ここで、先ほど紹介した save_as_draft! がそのまま再利用されています。一括アップロードで読み取った情報がフォームオブジェクト経由でJSONに変換され、下書きとして保存されます。このように、下書き機能を汎用的に設計しておいたことで、一括アップロード機能との連携がスムーズに実現できました。
事業部からの反応
リリースから1週間後に事業部へヒアリングを実施したところ、便利になった という声をいただくことができました。特に印象的だったフィードバックを紹介します。
- 一番良かった点は 住所の重複チェックの穴埋め。一括アップロード機能の導入により、住所の重複チェックの漏れが減少し、一括での重複チェックも可能になった
- 従来1件ずつ行っていた作業を一括で行えるようになり、待機時間が削減された
- 作業時間は1回につき約30秒短縮。月500件の物件登録に換算すると、十分な作業時間削減に貢献できている
- 下書き機能の追加により登録前の状態で入力内容を保存できるようになり、段階的に進められるようになったことで作業性が向上した
- 下書き機能と一括アップロード機能と併用することで、物件データの読み込み後の状態から登録作業を開始できるようになり、登録作業の業務負荷は 軽くなった と実感。今後も使い続けたい
下書き機能の設計時に意識していた物件登録を段階的に進めることや、一括アップロード機能で目指していた待機時間の削減が、実際の業務現場でも効果を実感していただけた結果となりました。
まとめ
今回は、物件登録業務を効率化するために開発した「下書き機能」と「マイソクの一括アップロード機能」について紹介しました。
- 下書き機能では、フォームオブジェクトの属性をJSONでまるごと保存するアプローチにより、項目の追加にも強い柔軟な設計を実現しました
- 一括アップロード機能では、S3 Presigned URLによるダイレクトアップロード、Sidekiq Throttledによる並列数制御、エラー時の継続処理など、大量データを安定して処理するための工夫を盛り込みました
- 両機能を組み合わせることで、「一括アップロード → OCRと生成AIによる読み取り → 下書き登録 → 確認 → 本登録」という一連の業務フローを効率化できました
事業部からも便利になった、負荷が軽くなったという声をいただけており、約4時間の作業時間削減に貢献できています。
以前のブログ記事で紹介した物件登録補助機能を起点に、業務の変化(パートナー制度の開始)に合わせて機能を拡張してきた事例として、少しでも参考になれば幸いです。