スポンサーリンク

ワンストップ特例申請書を出力するWebアプリを作った

rubyのsinatraを使ってふるさと納税のワンストップ特例申請書を出力するWebアプリケーションを作成しました。

画面イメージ

以下のような画面となりました。

使い方

  1. フォームに入力する
  2. 「PDF作成」ボタンをクリックする

デモサイト

デモサイトを用意しました。
ワンストップ特例申請書ジェネレータ

最初はunicornやpumaをnginxと連携させる方向で進めてたのですが途中からWindowsの開発環境を維持したまま本番にデプロイする方法がよくわからなくなってしまった(^q^
結局、Windowsの開発環境と同じようにwebrickをHTTPサーバー機能を利用しています。
複数台で同時にアクセスすると1つのプロセスが順番にリクエストを処理するので後にアクセスした方で少し待ち時間が発生してしまうと思います。

たぶん開発環境と本番環境のソースを同一の状態とするにはDocker(nginx)+pumaのような構成が必要なんだと思うけど、簡単なツールという趣旨から外れる気がするのでこのままでいい気もする。。

一応プロジェクト内にserver.shという起動スクリプトのようなものを作成してみました。
server.sh startで起動、server.sh stopで停止します。停止の仕方はkillallでrubyと名の付くプロセスをまとめてキルしてるので環境によっては注意が必要です。

開発環境について

rbenvを使用しています。
rubyのバージョンは2.6.0ですが、大体のバージョンで動くものと思われます。
IDEはデスクトップPCのEclipseにrubyのプラグインが入ってなかったので、VSCodeを使用しました。

rubyソースコード全文

require 'sinatra'
require 'sinatra/reloader'
require 'thinreports'
require 'date'
require 'wareki'
require 'logger'

# デバッグ用のロガー追加
logger = Logger.new(STDOUT)
logger.level = Logger::DEBUG

# views/index.erbを表示
get '/' do
  erb :index
end

# PDFをダウンロードするルーティング
post '/' do

  file_name = Time.now.strftime("%Y%m%d%H%M%S") + '_onestop.pdf'
  file_path = 'output/' + file_name

  # 生年月日
  unless params[:birth_date].empty?
    birth_date = Date.parse(params[:birth_date])
    birth_date_gengo = birth_date.strftime("%Je")
    birth_year = birth_date.strftime("%Jg")
    birth_month = birth_date.strftime("%-m")
    birth_day = birth_date.strftime("%-d")
  end

  # 出力年月日
  unless params[:output_date].empty?
    output_date = Date.parse(params[:output_date])
    output_year = output_date.strftime("%Jg")
    output_month = output_date.strftime("%-m")
    output_day = output_date.strftime("%-d")
  end

  # 寄附年月日
  unless params[:donation_date].empty?
    donation_date = Date.parse(params[:donation_date])
    donation_year = donation_date.strftime("%Jg")
    donation_month = donation_date.strftime("%-m")
    donation_day = donation_date.strftime("%-d")
  end

  # PDF生成
  report = Thinreports::Report.new layout: 'tlfs/onestop.tlf'
  report.start_new_page

  # tlfにフォームの入力内容を埋め込み
  report.page.item(:top_current_year).value(output_year)
  report.page.item(:current_year).value(output_year)
  report.page.item(:current_month).value(output_month)
  report.page.item(:current_day).value(output_day)
  report.page.item(:mayor).value(params[:mayor])
  report.page.item(:zip).value("〒" + params[:zip])
  report.page.item(:address).value(params[:address])
  report.page.item(:phone_number).value(params[:phone_number])
  report.page.item(:name_kana).value(params[:name_kana])
  report.page.item(:name).value(params[:name])
  report.page.item(:my_number).value(params[:my_number])
  case params[:sex]
  when 'man' then
    report.page.item(:man).value("〇")
  when 'woman' then
    report.page.item(:woman).value("〇")
  end
  case birth_date_gengo
  when '明治' then
    report.page.item(:year_meiji).value("〇")
  when '大正' then
    report.page.item(:year_taisyo).value("〇")
  when '昭和' then
    report.page.item(:year_syowa).value("〇")
  when '平成' then
    report.page.item(:year_heisei).value("〇")
  when '令和' then
    report.page.item(:year_reiwa).value("〇")
  end
  report.page.item(:birth_year).value(birth_year)
  report.page.item(:birth_month).value(birth_month)
  report.page.item(:birth_day).value(birth_day)
  report.page.item(:donation_year).value(donation_year)
  report.page.item(:donation_month).value(donation_month)
  report.page.item(:donation_day).value(donation_day)
  report.page.item(:donation_amount).value(number_format(params[:donation_amount]))
  if "yes" == params[:check_one] then
    report.page.item(:check_one).value("\u2713")
  end
  if "yes" == params[:check_two] then
    report.page.item(:check_two).value("\u2713")
  end

  # PDF生成
  report.generate filename: file_path

  # PDFダウンロード
  # ※レスポンスに書き込む前にPDFファイルを削除する。
  # バイナリ形式のままファイルを読み込みローカル変数に格納→ファイル削除→レスポンスに書き込み
  #(ずばりこれっていう回答が見つけられなかったので他にやり様があるような気もする)
  # binreadについて参考:https://qiita.com/kimitaka/items/f50fc3cea8243d1125a9
  file_data = File.binread(file_path)
  File.delete(file_path)
  headers['Content-Type'] = "application/octet-stream"
  headers['Content-Length'] = file_data.length
  headers['Content-Disposition'] = "attachment;filename=\"" + file_name + "\""
  file_data
end

# カンマ区切りの数字文字列を返す
def number_format(num_string)
  '\\' + num_string.reverse.scan(/\d{3}|.+/).join(",").reverse
end

1ファイルで足りるのがすごい便利。
ルートへのgetリクエストでindexビューを表示して、PDF作成ボタンクリック時のPOSTリクエストでPDFファイルを生成して返しています。

日付の処理について

生年月日、出力年月日、寄附年月日等の日付項目の処理にwarekiというライブラリを使用しました。
rubyのDateクラスを拡張するライブラリで元のstrftime()のフォーマット記号を変えるだけで良い当たりがかっこいいしすごく便利。

# 生年月日
unless params[:birth_date].empty?
  birth_date = Date.parse(params[:birth_date])
  birth_date_gengo = birth_date.strftime("%Je")
  birth_year = birth_date.strftime("%Jg")
  birth_month = birth_date.strftime("%-m")
  birth_day = birth_date.strftime("%-d")
end

# 出力年月日
unless params[:output_date].empty?
  output_date = Date.parse(params[:output_date])
  output_year = output_date.strftime("%Jg")
  output_month = output_date.strftime("%-m")
  output_day = output_date.strftime("%-d")
end

# 寄附年月日
unless params[:donation_date].empty?
  donation_date = Date.parse(params[:donation_date])
  donation_year = donation_date.strftime("%Jg")
  donation_month = donation_date.strftime("%-m")
  donation_day = donation_date.strftime("%-d")
end

PDFの出力について

PDFファイルの出力にはthinreportsを使用しました。

# PDF生成
report = Thinreports::Report.new layout: 'tlfs/onestop.tlf'
report.start_new_page

# tlfにフォームの入力内容を埋め込み
report.page.item(:top_current_year).value(output_year)
report.page.item(:current_year).value(output_year)
report.page.item(:current_month).value(output_month)
report.page.item(:current_day).value(output_day)
report.page.item(:mayor).value(params[:mayor])
report.page.item(:zip).value("〒" + params[:zip])
report.page.item(:address).value(params[:address])
report.page.item(:phone_number).value(params[:phone_number])
report.page.item(:name_kana).value(params[:name_kana])
report.page.item(:name).value(params[:name])
report.page.item(:my_number).value(params[:my_number])
case params[:sex]
when 'man' then
  report.page.item(:man).value("〇")
when 'woman' then
  report.page.item(:woman).value("〇")
end
case birth_date_gengo
when '明治' then
  report.page.item(:year_meiji).value("〇")
when '大正' then
  report.page.item(:year_taisyo).value("〇")
when '昭和' then
  report.page.item(:year_syowa).value("〇")
when '平成' then
  report.page.item(:year_heisei).value("〇")
when '令和' then
  report.page.item(:year_reiwa).value("〇")
end
report.page.item(:birth_year).value(birth_year)
report.page.item(:birth_month).value(birth_month)
report.page.item(:birth_day).value(birth_day)
report.page.item(:donation_year).value(donation_year)
report.page.item(:donation_month).value(donation_month)
report.page.item(:donation_day).value(donation_day)
report.page.item(:donation_amount).value(number_format(params[:donation_amount]))
if "yes" == params[:check_one] then
  report.page.item(:check_one).value("\u2713")
end
if "yes" == params[:check_two] then
  report.page.item(:check_two).value("\u2713")
end

# PDF生成
report.generate filename: file_path

こないだも似たようなこと書いた気がするけどレイアウトに関する設定がすべてtlfファイル側で完結してるので、rubyソースが見やすい。
テンプレートのtlfファイルはバイナリではなく整形されたjsonテキストなので少し位置を変えたりした際、比較エディタできっちり差分が確認できるのがいいなーと思いました。

PDFのダウンロード処理について

住所、氏名、マイナンバーなどの個人情報を入力するので、ローカルにファイルは持ち続けない方が良さそう。
ということでダウンロード処理と合わせてファイルの削除を行うようにプログラムを修正したのですが結構はまった。。

以下は最初の実装です。

# PDFをダウンロードするルーティング
post '/' do

~~~省略~~~

  # PDF生成
  report.generate filename: file_path

  # PDFダウンロード
  stat = File::stat(file_path)
  send_file(file_path, :filename => file_name, :length => stat.size, :type => 'application/octet-stream')
end

ファイルの削除は特に考慮していなかった頃のソースです。
send_fileでプログラムが閉じてるので当然outputディレクトリにファイルが残り続けます。


次にafterを使った実装。
これはsend_fileのレスポンスが返るタイミングとafterが呼ばれるタイミングが同期してない?ようで、send_fileの際にファイルが見つからないことが原因のサーバーエラーになってしまいました。。
そもそもNGパターンですが一応備忘録として残しておく。

enable :sessions

# PDFをダウンロードするルーティング
post '/' do

~~~省略~~~

  # PDF生成
  report.generate filename: file_path

  # セッションにファイルパスを書き込み
  session[:file_path] = file_path

  # PDFダウンロード
  stat = File::stat(file_path)
  send_file(file_path, :filename => file_name, :length => stat.size, :type => 'application/octet-stream')
end

# PDF削除
after '/' do
  FileUtils.rm_f("./" + session[:file_path].to_s)
end

最終的に以下のようなプログラムとなりました。

# PDFダウンロード
# ※レスポンスに書き込む前にPDFファイルを削除する。
# バイナリ形式のままファイルを読み込みローカル変数に格納→ファイル削除→レスポンスに書き込み
#(ずばりこれっていう回答が見つけられなかったので他にやり様があるような気もする)
# binreadについて参考:https://qiita.com/kimitaka/items/f50fc3cea8243d1125a9
file_data = File.binread(file_path)
File.delete(file_path)
headers['Content-Type'] = "application/octet-stream"
headers['Content-Length'] = file_data.length
headers['Content-Disposition'] = "attachment;filename=\"" + file_name + "\""
file_data

探し方が悪いのか100%これでOKの回答が見つけられませんでした。。;;
最初のPDFダウンロード処理のレスポンスヘッダーの情報をメモ帳に記録しておいて同じものが出力されるよう都度確認しながら進めたところ上記のようなプログラムとなりました。

参考サイトなど

Sinatra: README (Japanese)
sinatraの使い方など。今回はうまく行かなかったけどafterとかsessionの使い方を調べました。

sugi/wareki: ruby 向け和暦ライブラリ。和暦と標準Dateの双方向変換をサポート。全元号対応。
warekiライブラリの使い方など。

Ruby の IO.read でバイナリファイルを読み込むときの注意点 – Qiita
最初、File.readで読み込んだファイルデータをレスポンスに書き込んでたら、send_fileで出力したファイルとサイズが違ったり、PDFビューワで開くと破損してたりしてたのでここの情報を参考にFile.binreadに修正しました。

Bootstrap Form Builder
フォームの土台を作成するのに使わせていただきました。

Bg-patterns 背景パターン配布&作成サイト | 商用可能なパターン背景素材をフリー(無料)配布。
背景素材を使わせていただきました。

まとめ

sinatraとthinreportsの使い勝手がすごい良かった。
PHPとかJavaを使ってるプロジェクトでも帳票出力だけruby(sinatra+thinreports)を使うというものありなのかなーと思いました。

今回作成したプロジェクトもGitHubにパブリックリポジトリとして公開しています。
GitHub:imo-tikuwa/ruby-onestop-tokurei-gen

ちなみに今年度分のふるさと納税での使用は間に合いませんでした。
寄附先の自治体に実際に申請書を発行して送付した実績はない状態となっています。
心配な方はふるさと納税のポータルサイトの指示に従い、各自治体から送られてきた申請書を使用された方が良いと思います。
私は来年度分のふるさと納税の際に使ってみるかも。

年末くらいにWindowsPCの環境構築から始まり何とか一つアプリケーションを作ることが出来ました。
仕事で使うRailsの勉強は全く進んでないですがそちらはOJT(現在進行形)で頑張ろうと思います(^^;

タイトルとURLをコピーしました