見積書は、1枚の紙の中に「宛先・件名・日付」と「明細の行(品名・数量・単価)」が同居しています。Excelなら行をコピーして足すだけですが、システムにするときは「1枚の見積書」と「何行もの明細」をどう持つかを最初に決める必要があります。この第3回では、見積書(親)と明細行(子)をRuby on Rails 8で作り、明細行を画面上でその場で追加・削除できるところまで進めます。
前回の第2回:ログインを付けるで、ログインと3段階の権限(閲覧のみ/担当者/管理者)を入れました。今回の見積書の画面も、その権限の仕組みにそのまま乗せます。
「Excelの見積書は行を足すだけなのに、システムにすると『明細を増やす』だけで追加費用と言われました。そんなに大変なことなんですか?」
先に結論です。見積書と明細を「親と子」として設計し、明細の追加・削除を1つの保存操作にまとめれば、Railsではかなり少ない部品で作れます。 大変になりやすいのは明細行そのものではなく、その先の「金額・消費税・端数の計算」(第4回)と「あとから金額が変わらない仕組み」のほうです。今回は後者の土台として、明細に品名や単価を「写して」持たせる設計も入れます。
この記事では、モデルの設計、入れ子のフォーム(=1つの画面で親と子を一緒に保存する入力欄)、明細行を追加する小さなJavaScript、そして自動テストまでを、実際に動かしたコードで解説します。
見積書と明細を「親と子」で持つ考え方
まず、データの持ち方を整理します。見積書のデータは、次の2つの表に分けて持ちます。
| テーブル | 1行が表すもの | 主な項目 |
|---|---|---|
| estimates(見積書) | 見積書1枚 | 取引先、見積番号、件名、発行日、有効期限、備考 |
| estimate_lines(見積明細) | 見積書の中の1行 | 見積書への紐づけ、並び順、品名、単位、数量、単価、消費税率 |
1枚の見積書に明細が何行もつながる関係なので、見積書が「親」、明細が「子」です。Railsでは、これを has_many(=1対多の関係)と belongs_to で表します(モデルの関連づけの公式ガイドは下記です)。
📰 出典:Rails Guides「Active Record Associations」
ここで大事なのが、明細が品目マスタを「参照」するだけでなく、品名・単価・税率を「写して」持つ点です。
- 品目マスタの単価を後から改定したとき、過去の見積書の金額が勝手に変わってはいけない
- 見積書は「その時点の条件で出した書類」なので、後から変わらないのが当たり前
そのため明細には、品目との紐づけ(item_id)を任意で持たせつつ、品名・単位・単価・税率は明細自身の列として保存します。品目を選んで入力を楽にする機能は、次回以降に足せます。
実装手順
1. テーブルを作る(マイグレーション)
db/migrate/20261002184505_create_estimates.rb から、要点を抜粋します。
create_table :estimates do |t|
t.references :customer, null: false, foreign_key: true
t.string :number
t.string :title, null: false
t.date :issued_on, null: false
t.date :valid_until
t.text :note
t.timestamps
end
add_index :estimates, :number, unique: true
create_table :estimate_lines do |t|
t.references :estimate, null: false, foreign_key: true
t.references :item, foreign_key: true
t.integer :position, null: false, default: 0
t.string :name, null: false
t.string :unit
t.integer :quantity, null: false, default: 1
t.integer :unit_price, null: false, default: 0
t.integer :tax_rate, null: false, default: 10
t.timestamps
end
foreign_key: true は、存在しない取引先や見積書に紐づくデータを、データベース側で作れなくする指定です。アプリのチェックをすり抜けても、データベースが最後の砦になります。なお、金額は小数の誤差が出ないよう、すべて整数(円)で持ちます。
2. 親と子のモデルを書く
app/models/estimate.rb です。
class Estimate < ApplicationRecord
belongs_to :customer
# 明細は見積書と一緒に保存・削除する。空の行(品名なし)は保存しない
has_many :lines, -> { order(:position, :id) }, class_name: "EstimateLine", dependent: :destroy, inverse_of: :estimate
accepts_nested_attributes_for :lines, allow_destroy: true, reject_if: ->(attrs) { attrs["name"].blank? }
validates :title, presence: true, length: { maximum: 100 }
validates :issued_on, presence: true
validates :valid_until, comparison: { greater_than_or_equal_to: :issued_on }, allow_blank: true, if: :issued_on
validate :must_have_line
after_create :assign_number
# 税抜の合計(税の計算は第4回)
def subtotal
lines.reject(&:marked_for_destruction?).sum(&:amount)
end
private
# 見積番号は保存後のIDから作る(例: Q-00012)。欠番になりにくく、重複しない
def assign_number
update_column(:number, format("Q-%05d", id))
end
def must_have_line
errors.add(:lines, "を1行以上入力してください") if lines.reject(&:marked_for_destruction?).empty?
end
end
ポイントを順に説明します。
accepts_nested_attributes_for :lines:見積書の保存と一緒に、明細の追加・更新・削除もまとめて受け付けるRailsの仕組みです。これがあると、1回の「保存する」ボタンで親と子が一括で保存されます。allow_destroy: true:画面から「この行を削除」と伝えられるようにします(後述の_destroy)。reject_if:品名が空の行は、エラーにせず無視します。画面に空の行が残っていても保存が止まらないための配慮です。dependent: :destroy:見積書を消したら、明細も一緒に消します。must_have_line:明細が1行もない見積書は保存できません。削除予定の行(marked_for_destruction?)は数えないのがコツです。- 見積番号:保存した後にIDから
Q-00012の形で作ります。今回は小さく始めるための簡易的な方式で、年度ごとの連番や欠番を許さない運用が必要な場合は、請求書の採番を扱う第7回で改めて考えます。
明細側の app/models/estimate_line.rb は短く済みます。
class EstimateLine < ApplicationRecord
belongs_to :estimate
belongs_to :item, optional: true
validates :name, presence: true, length: { maximum: 100 }
validates :quantity, numericality: { only_integer: true, greater_than: 0 }
validates :unit_price, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tax_rate, inclusion: { in: Item::TAX_RATES }
# 金額(税抜)= 数量 × 単価
def amount = quantity * unit_price
end
belongs_to :item, optional: true は「品目マスタとの紐づけは無くてもよい」という意味です。マスタに無い作業を、その場で書き足せるようにしています。数量は今回は整数に限定しており、「0.5人月」のような小数の数量は扱いません(必要になったら第4回の計算とあわせて見直します)。
3. 画面から親と子を一緒に受け取る(コントローラー)
app/controllers/estimates_controller.rb の要点は、受け取る項目(Strong Parameters)の書き方です。
def estimate_params
params.expect(estimate: [ :customer_id, :title, :issued_on, :valid_until, :note,
lines_attributes: [ [ :id, :item_id, :position, :name, :unit, :quantity, :unit_price, :tax_rate, :_destroy ] ] ])
end
lines_attributes の中に、明細1行ぶんの項目を並べています。_destroy は「この行を削除する」という合図で、id は「保存済みのどの行か」を表します。
権限は第2回の仕組みをそのまま使い、先頭の2行で済ませています。
require_permission :can_edit?, except: %i[ index show ]
require_permission :can_destroy?, only: :destroy
閲覧のみのユーザーは一覧と詳細を見るだけ、担当者は作成・編集まで、削除は管理者だけ、という線引きが、品目・取引先と同じ書き方で揃います。
4. 入れ子のフォームを作る
app/views/estimates/_form.html.erb から、明細の部分を抜粋します。
<div data-controller="nested-form">
<table class="lines">
<tbody data-nested-form-target="container">
<%= form.fields_for :lines do |line_form| %>
<%= render "line_fields", form: line_form %>
<% end %>
</tbody>
</table>
<%# 行の追加用ひな形。NEW_RECORD は JavaScript が一意の数字に置き換える %>
<template data-nested-form-target="template">
<%= form.fields_for :lines, EstimateLine.new, child_index: "NEW_RECORD" do |line_form| %>
<%= render "line_fields", form: line_form %>
<% end %>
</template>
<p><button type="button" data-action="nested-form#add">明細行を追加</button></p>
</div>
fields_for :lines が、明細1行ぶんの入力欄を作ります。行の追加は「ひな形(<template>)を複製する」方式にしました。ひな形の中では、行の番号の代わりに NEW_RECORD という目印を入れておき、追加のたびにJavaScriptが重ならない数字へ置き換えます。
1行ぶんの入力欄は app/views/estimates/_line_fields.html.erb に切り出しています。
<tr data-nested-form-row data-new-record="<%= form.object.new_record? %>">
<%= form.hidden_field :id if form.object.persisted? %>
<%= form.hidden_field :_destroy %>
<td><%= form.text_field :name, required: true, placeholder: "品名" %></td>
<td><%= form.number_field :quantity, min: 1, step: 1, class: "short" %></td>
<td><%= form.text_field :unit, class: "short" %></td>
<td><%= form.number_field :unit_price, min: 0, step: 1 %></td>
<td><%= form.select :tax_rate, Item::TAX_RATES.map { |r| [ "#{r}%", r ] } %></td>
<td><button type="button" data-action="nested-form#remove">削除</button></td>
</tr>
5. 明細行を追加・削除する小さなJavaScript(Stimulus)
ここで使うのが、Rails 8に標準で入っている Hotwire(ホットワイヤー) の一部、Stimulus(スティミュラス) です。Hotwireは「大きなJavaScriptの画面部品を作らず、サーバーが作ったHTMLを活かしながら、必要な所だけ動きを足す」ためのRailsの標準的な仕組みで、Stimulusはその「必要な所だけ動きを足す」担当です。HTMLに data-controller のような印を付けておくと、対応するJavaScriptが動きます(公式の解説は下記のStimulus Handbookにあります)。
📰 出典:Stimulus Handbook(Hotwire公式)
app/javascript/controllers/nested_form_controller.js の全体です。
import { Controller } from "@hotwired/stimulus"
// 明細行の追加・削除。<template> の中身を複製し、NEW_RECORD を一意の数字に置き換えて追加する
export default class extends Controller {
static targets = [ "template", "container" ]
add(event) {
event.preventDefault()
const id = Date.now().toString()
this.containerTarget.insertAdjacentHTML("beforeend", this.templateTarget.innerHTML.replaceAll("NEW_RECORD", id))
}
remove(event) {
event.preventDefault()
const row = event.target.closest("[data-nested-form-row]")
if (row.dataset.newRecord === "true") {
// まだ保存していない行は、画面から消すだけでよい
row.remove()
} else {
// 保存済みの行は _destroy を立てて隠す(サーバー側で削除される)
row.querySelector("input[name$='[_destroy]']").value = "1"
row.hidden = true
}
}
}
20行ほどです。ファイルを controllers/ に置くだけで、index.js が自動で読み込みます(第0回の雛形の仕組みです)。
削除が2通りに分かれる理由は重要です。まだ保存していない行は、画面から消せば終わりです。一方、すでに保存済みの行は、画面から消すだけではサーバーに伝わらず、保存しても行が残ってしまいます。そこで _destroy に 1 を入れて隠し、保存したときにサーバー側で削除する形にしています。
なお、今回の動きは「ブラウザの中で行を足す」までで、サーバーとの通信は保存ボタンを押したときの1回だけです。Hotwireにはもう一つの柱のTurbo(画面の一部だけをサーバーのHTMLで書き換える仕組み)があり、金額の自動集計などで第4回以降に活躍します。
6. 一覧・詳細と、ほかの画面への影響
一覧(index)と詳細(show)では、各明細の「金額(税抜)=数量×単価」と、見積書の小計を表示します。税額と合計の計算は、端数処理が絡むため第4回でまとめて扱います。
もう一点、見積書ができると、取引先・品目の削除の扱いが変わります。開発中にテストが失敗して気づいた点です。
- 見積書がある取引先を消すと、見積書の宛先が無くなってしまう。そこで
has_many :estimates, dependent: :restrict_with_errorを取引先に足し、見積書がある取引先は削除できないようにしました(画面には「見積書がある取引先は削除できません」と出ます) - 品目は、削除しても明細に品名・単価を写してあるので、明細は残して紐づけだけ外します(
dependent: :nullify)
「消してよいデータ」と「記録として残すデータ」の線引きは、見積・請求のような帳票系のシステムでは最初に決めておきたい論点です。
動作確認:自動テストと、ブラウザでの操作確認
今回の機能は、次の観点を自動テストにしています(test/models/estimate_test.rb、test/controllers/estimates_controller_test.rb)。
- 明細の金額が「数量×単価」になり、小計が合計になる
- 保存すると見積番号(
Q-00001の形)が付く - 明細が1行もない見積書は保存できない
- 品名が空の行は無視される
- 数量が0の明細、発行日より前の有効期限は保存できない
- 見積書を削除すると明細も消える
- 閲覧のみの権限では作成できず、削除は管理者だけができる
- 編集画面で明細を削除できる
検証は次のコマンドで行い、今回の変更後は46件のテストがすべて通り、RuboCop(コードの書き方の自動チェック)も指摘なしでした。
cd blogs/it_hacchu/series/rails-mitsumori/code
bin/rails test
bin/rubocop
テストでは確かめられない「画面上で行を足す・消す」動きは、開発環境でブラウザ(Chromium)を自動操作して確認しました。具体的には、①ログイン後に作成画面を開くと明細が1行ある、②「明細行を追加」を2回押すと3行になる、③追加した行の「削除」で2行に戻る、④2行に入力して保存すると見積書の詳細画面に移る、までを確かめています(開発環境での確認です)。
この確認で、実際にひとつ不具合を見つけました。最初に書いた削除の処理は「_destroy の入力欄があれば隠す」という作りで、新しく追加した行にも同じ入力欄があるため、追加した行が画面から消えず「隠れただけ」になっていました。そのまま保存すると、隠れた行は品名が入力できないまま残ります。上のコードの data-new-record(新しい行かどうかの印)による分岐は、この修正です。画面の動きは、自動テストだけでは見逃しやすい部分でした。
つまずきやすい点・注意
_destroyとidの取りこぼし:受け取る項目(Strong Parameters)にidと_destroyを入れ忘れると、編集時に明細が「更新」ではなく「新しい行として追加」され、同じ明細が二重になります- 行番号の重複:追加する行の番号(
NEW_RECORDの置き換え先)は、他の行と重ならない値にします。今回は時刻のミリ秒を使っていますが、極めて短い間隔で連続追加すると重なる理論上の余地があります(人の操作では起きにくい水準です) - 並び順(
position):列は用意しましたが、画面での並べ替えは今回扱っていません。入力した順に保存されます - 「保存したあとに金額が変わらない」仕組みは別問題:品名・単価を明細に写して持つのはその第一歩ですが、見積書の「確定(発行済み)」状態で編集を止める仕組みは、請求書への変換(第7回)とあわせて扱います
- この連載で省略していること:見積書の複製、品目マスタからの選択入力、明細の並べ替え、小数の数量、値引き行は今回扱いません。必要な場合は、その分が別の工数になります
発注者向けメモ:見積書機能を頼むときの確認点
見積書の機能は「どこまで自動で作るか」で費用が大きく変わります。開発会社に頼むときは、次を確認してください。
- ☐ 今の見積書のExcelやPDFの現物を見せ、「この項目は必須」「この欄は空でもよい」を分けたか
- ☐ 明細の数量に小数(例:0.5人月)や、値引き・「一式」の行が必要か伝えたか
- ☐ 見積番号のルール(年度ごとの連番、取引先ごとの記号など)を決めているか
- ☐ 見積書を出したあとの修正を認めるか(認めるなら、改版の履歴を残すか)
- ☐ 品目マスタの単価を変えたとき、過去の見積書は変わらない前提で合意できているか
- ☐ 取引先や品目を削除したい要望が出たとき、過去の見積書をどう扱うか決めているか
開発会社への質問例
- 「明細の行数に上限はありますか。100行を超える見積書でも画面は問題なく動きますか」
- 「見積書を発行したあとに内容を直す場合、元の見積書はどう残りますか。改版の履歴は取れますか」
- 「見積番号のルール(年度・連番・取引先記号)は、あとから変えられますか。変えたとき、過去の番号はどうなりますか」
- 「品目マスタの価格を改定しても、すでに出した見積書の金額が変わらないことは、どう保証されていますか」
工数が増えやすいのは、小数の数量・値引きや「出精値引き」などの特殊な行、見積書の改版管理、承認フロー(上長の承認を経てから発行する)、そして既存のExcel見積書とそっくり同じ見た目での出力です。最初は「入力して、保存して、一覧で探せる」ところまでを小さく作り、使いながら足していく進め方が現実的です。
「明細の追加くらい簡単だと思っていましたが、『あとから金額が変わらないこと』や『消してよいデータの線引き』のほうが、先に決めておくべき大事な話なんですね。」
まとめと次回予告
第3回では、見積書(親)と明細行(子)をRails 8で作り、Stimulusの20行ほどのJavaScriptで明細行をその場で追加・削除できる画面を作りました。品名・単価を明細に写して持つこと、見積書がある取引先は消せないことなど、帳票系のシステムで最初に決めておきたい線引きも入れています。
次回の第4回は「金額・消費税・端数処理を正しく計算する」です。税率ごとの集計と端数処理の考え方を整理し、Minitestで計算結果を固めます。見積・請求システムで最も「間違えてはいけない」部分です。
この連載の記事一覧
この記事は連載「Railsで作る見積・請求管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Railsで作る見積・請求管理システム 第0回】Rails 8の雛形を動かして、Excel見積からの脱却を始める
- 【Railsで作る見積・請求管理システム 第1回】取引先と品目のマスタを作る(scaffoldと入力チェック)
- 【Railsで作る見積・請求管理システム 第2回】ログインを付ける(Rails 8標準の認証と権限の分け方)
- 【Railsで作る見積・請求管理システム 第3回】見積書と明細を作る(Hotwireで明細行をその場で追加)(この記事)
- 【Railsで作る見積・請求管理システム 第4回】金額・消費税・端数処理を正しく計算する(税率ごとに1回だけ丸める)
- 【Railsで作る見積・請求管理システム 第5回】見積書をPDFで出力する(Prawnで日本語フォントを扱う)
- 【Railsで作る見積・請求管理システム 第6回】見積書をメールで送る(Solid Queueで送信を非同期にする)
- 【Railsで作る見積・請求管理システム 第7回】受注から請求書へ変換する(状態遷移と請求番号、インボイスの記載事項)
- 【Railsで作る見積・請求管理システム 第8回】入金管理と督促の一覧を作る(一部入金・期限超過の抽出)










コメント
コメント一覧 (4件)
[…] 前回の第3回:見積書と明細を作るでは、見積書(親)と明細行(子)を作り、画面で明細を追加・削除できるようにしました。ただし金額は「税抜の小計」だけで、消費税はまだ計算していませんでした。 […]
[…] […]
[…] […]
[…] […]