T Thingnoy
Home Ruby on Rails
Part 3

Ruby on Rails — ลงมือสร้างระบบ Payroll + ของดีที่มากับ Rails

อ่าน ~224 นาที
ruby rails tutorial hands-on
Ruby on Rails — ลงมือสร้างระบบ Payroll + ของดีที่มากับ Rails
Table of Contents

Ruby on Rails — ลงมือสร้างระบบ Payroll + ของดีที่มากับ Rails

“3 คำสั่ง ได้ CRUD เต็มรูปแบบ — scaffold generate, db:migrate, server”

อ่าน Part 1-2 มาแล้ว รู้ว่า Rails คืออะไร ทำงานยังไง — ตอนนี้ถึงเวลาลงมือสร้างจริง จะพาสร้าง Payroll app (ระบบเงินเดือน + ลงเวลา) ตั้งแต่ศูนย์ ทีละ step แล้วต่อด้วยของดีๆ ที่ Rails แถมมาให้ฟรี รวมถึง Authentication กับ API mode ที่ใช้กันใน production จริง


Part 1 — Get Started: สร้างระบบ Payroll ด้วย Rails ตั้งแต่ศูนย์

Step 0 — ติดตั้ง Ruby และ Rails

macOS

# ติดตั้ง Homebrew (ถ้ายังไม่มี)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# ติดตั้ง rbenv — ตัวจัดการ Ruby version
brew install rbenv ruby-build

# เพิ่ม rbenv เข้า shell (ถ้าใช้ zsh)
echo 'eval "$(rbenv init - zsh)"' >> ~/.zshrc
source ~/.zshrc

# ติดตั้ง Ruby
rbenv install 3.4.5
rbenv global 3.4.5    # ตั้งเป็น default version

# เช็คว่าได้แล้ว
ruby -v   # => ruby 3.4.5

Linux (Ubuntu/Debian)

sudo apt update
sudo apt install -y git curl libssl-dev libreadline-dev zlib1g-dev

# ติดตั้ง rbenv
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer | bash
echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
source ~/.bashrc

rbenv install 3.4.5
rbenv global 3.4.5

Windows

# วิธีง่ายสุด — ใช้ WSL2 (Windows Subsystem for Linux) แล้วทำตาม Linux ข้างบน
# หรือใช้ RubyInstaller: https://rubyinstaller.org

ติดตั้ง Rails

# gem คือ package manager ของ Ruby (เหมือน npm ของ Node, pip ของ Python)
gem install rails

# เช็คว่าได้แล้ว
rails -v   # => Rails 8.1.x

Step 1 — สร้าง Project ใหม่

มี 2 แบบ:

แบบ A — ใช้ SQLite (ง่ายสุด ไม่ต้อง setup อะไร)

rails new payroll_app

SQLite เก็บ database เป็นไฟล์ตัวเดียว ไม่ต้องติดตั้ง database server แยก — เหมาะสำหรับเรียนรู้และ prototype

แบบ B — ใช้ PostgreSQL + Docker Compose (เหมือน production จริง)

ถ้าอยากใช้ PostgreSQL ตั้งแต่แรก (แนะนำ เพราะ production ส่วนใหญ่ใช้ PostgreSQL):

rails new payroll_app -d postgresql

flag -d postgresql บอก Rails ให้ config database.yml สำหรับ PostgreSQL แทน SQLite

ปัญหาคือ — ต้องมี PostgreSQL server รันอยู่ก่อน ติดตั้ง native บน macOS/Linux/Windows มันยุ่ง ทางที่ง่ายที่สุดคือใช้ Docker Compose:

ติดตั้ง Docker Desktop — โหลดจาก docker.com แล้ว install ตามปกติ (ทุก OS)

สร้างไฟล์ docker-compose.yml ไว้ที่ root ของ project:

# docker-compose.yml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

แก้ config/database.yml ส่วน development ให้ชี้ไป Docker:

development:
  <<: *default
  database: payroll_app_development
  username: postgres
  password: postgres
  host: localhost
  port: 5432

แล้ว start PostgreSQL:

docker compose up -d    # start PostgreSQL container (รันอยู่ background)
rails db:create         # สร้าง database

เท่านี้จบ — ไม่ต้อง install PostgreSQL ลงเครื่องเลย Docker จัดการให้หมด

tip: docker compose up -d รันทิ้งไว้ได้เลย กิน RAM ไม่มาก หยุดได้ด้วย docker compose down (database ไม่หาย เพราะเก็บใน volume)


cd payroll_app

ดู structure ที่ได้:

payroll_app/
├── app/                    # code หลักอยู่ที่นี่
│   ├── controllers/        # รับ request, เรียก model, ส่ง response
│   ├── models/             # business logic, database interaction
│   ├── views/              # HTML templates
│   │   └── layouts/        # layout หลัก (เหมือน template ครอบ)
│   ├── helpers/            # view helper methods
│   ├── javascript/         # JS (Hotwire/Turbo)
│   ├── jobs/               # background jobs
│   ├── mailers/            # email
│   └── channels/           # WebSocket (Action Cable)
├── config/
│   ├── routes.rb           # URL routing — กำหนดว่า URL ไหนไป controller ไหน
│   ├── database.yml        # database config
│   └── environments/       # config แยกตาม environment
│       ├── development.rb
│       ├── test.rb
│       └── production.rb
├── db/
│   ├── migrate/            # database migrations
│   ├── schema.rb           # snapshot ของ database schema ปัจจุบัน
│   └── seeds.rb            # seed data สำหรับ development
├── test/                   # tests
├── public/                 # static files (favicon, 404.html)
├── Gemfile                 # dependencies (เหมือน package.json)
└── Gemfile.lock            # locked versions (เหมือน package-lock.json)

Step 2 — รัน server ครั้งแรก

# ถ้าใช้ PostgreSQL ต้อง start Docker ก่อน (ข้ามถ้าใช้ SQLite)
docker compose up -d

# สร้าง database (ถ้ายังไม่ได้สร้าง)
rails db:create

# start Rails server
rails server
# หรือแบบสั้น
rails s

เปิด browser ไปที่ http://localhost:3000 — จะเห็นหน้า welcome ของ Rails

กด Ctrl+C เพื่อหยุด server (เดี๋ยวเปิดใหม่ทีหลัง)

Step 3 — สร้าง Model แรก

# generate migration สำหรับสร้าง table employees
rails generate model Employee name:string salary:decimal position:string

คำสั่งนี้สร้างให้ 3 อย่าง:

create  db/migrate/20260309134857_create_employees.rb   # migration file
create  app/models/employee.rb                           # model file
create  test/models/employee_test.rb                     # test file

ดู migration file ที่ generate มา:

# db/migrate/20260309134857_create_employees.rb
class CreateEmployees < ActiveRecord::Migration[8.1]
  def change
    create_table :employees do |t|
      t.string :name
      t.decimal :salary
      t.string :position

      t.timestamps   # สร้าง created_at และ updated_at ให้อัตโนมัติ
    end
  end
end

รัน migration เพื่อสร้าง table จริงใน database:

rails db:migrate

output:

== CreateEmployees: migrating =================================================
-- create_table(:employees)
   -> 0.0012s
== CreateEmployees: migrated (0.0013s) ========================================

ถ้าใช้ SQLite: ไฟล์ database อยู่ที่ db/development.sqlite3 — ไม่ต้อง setup อะไรเลย ถ้าใช้ PostgreSQL + Docker: ตรวจสอบว่า docker compose up -d รันอยู่

Step 4 — ลองเล่นกับ Model ใน Rails Console

rails console
# หรือแบบสั้น
rails c

Rails Console คือ interactive Ruby shell ที่ load app ทั้งตัวมาให้ — ลอง CRUD ได้เลย:

# สร้าง employee
Employee.create(name: "Somchai", salary: 30000, position: "Developer")
# => INSERT INTO "employees" ("name", "salary", "position", "created_at", "updated_at")
#    VALUES ('Somchai', 30000, 'Developer', '2026-03-09 13:48:57', '2026-03-09 13:48:57')

# อ่านทั้งหมด
Employee.all
# => [#<Employee id: 1, name: "Somchai", salary: 30000, position: "Developer", ...>]

# หาด้วย id
emp = Employee.find(1)

# หาด้วยเงื่อนไข
Employee.where("salary > ?", 25000)
Employee.where(position: "Developer")

# อัปเดต
emp.update(salary: 35000)

# ลบ
emp.destroy

# สร้างเพิ่มอีกสัก 2-3 คน สำหรับ step ถัดไป
Employee.create(name: "Somchai", salary: 30000, position: "Developer")
Employee.create(name: "Somsri", salary: 54000, position: "Manager")
Employee.create(name: "Junior", salary: 20000, position: "Intern")

# นับ
Employee.count        # => 3
Employee.where("salary > ?", 25000).count  # => 2

# exit console
exit

Step 5 — สร้าง Controller และ Route

rails generate controller Employees index show

ได้:

create  app/controllers/employees_controller.rb    # controller
create  app/views/employees/index.html.erb         # view สำหรับ index
create  app/views/employees/show.html.erb          # view สำหรับ show
 route  get 'employees/index'
        get 'employees/show'

แก้ routes ให้เป็น RESTful:

# config/routes.rb
Rails.application.routes.draw do
  root "employees#index"       # หน้าแรก → employees#index
  resources :employees          # สร้าง 7 RESTful routes ให้อัตโนมัติ
end

ดู routes ทั้งหมดที่ได้:

rails routes

output:

      Prefix Verb   URI Pattern                    Controller#Action
        root GET    /                              employees#index
   employees GET    /employees(.:format)           employees#index
             POST   /employees(.:format)           employees#create
new_employee GET    /employees/new(.:format)       employees#new
edit_employee GET   /employees/:id/edit(.:format)  employees#edit
    employee GET    /employees/:id(.:format)       employees#show
             PATCH  /employees/:id(.:format)       employees#update
             DELETE /employees/:id(.:format)       employees#destroy

resources :employees บรรทัดเดียว → 8 routes

Step 6 — เขียน Controller Logic

# app/controllers/employees_controller.rb
class EmployeesController < ApplicationController
  def index
    @employees = Employee.all
  end

  def show
    @employee = Employee.find(params[:id])
  end

  def new
    @employee = Employee.new
  end

  def create
    @employee = Employee.new(employee_params)

    if @employee.save
      redirect_to @employee, notice: "Employee was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    @employee = Employee.find(params[:id])
  end

  def update
    @employee = Employee.find(params[:id])

    if @employee.update(employee_params)
      redirect_to @employee, notice: "Employee was successfully updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @employee = Employee.find(params[:id])
    @employee.destroy
    redirect_to employees_url, notice: "Employee was successfully destroyed."
  end

  private

  # Strong Parameters — whitelist เฉพาะ field ที่อนุญาต
  # ป้องกัน mass assignment attack (เช่น user แอบส่ง admin: true มาด้วย)
  def employee_params
    params.require(:employee).permit(:name, :salary, :position)
  end
end

อธิบาย flow:

  1. User เปิด /employees → Rails เรียก EmployeesController#index
  2. Controller ดึงข้อมูลจาก Model → Employee.all
  3. เก็บไว้ใน instance variable @employees → ส่งต่อให้ View อัตโนมัติ
  4. Rails render app/views/employees/index.html.erb

Step 7 — เขียน View

<!-- app/views/employees/index.html.erb -->
<h1>Employees</h1>

<%= link_to "New Employee", new_employee_path %>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Position</th>
      <th>Salary</th>
    </tr>
  </thead>
  <tbody>
    <% @employees.each do |employee| %>
      <tr>
        <td><%= link_to employee.name, employee %></td>
        <td><%= employee.position %></td>
        <td><%= number_to_currency(employee.salary, unit: "฿") %></td>
      </tr>
    <% end %>
  </tbody>
</table>
<!-- app/views/employees/show.html.erb -->
<h1><%= @employee.name %></h1>
<p><strong>Position:</strong> <%= @employee.position %></p>
<p><strong>Salary:</strong> <%= number_to_currency(@employee.salary, unit: "฿") %></p>

<%= link_to "Edit", edit_employee_path(@employee) %> |
<%= link_to "Back to Employees", employees_path %>
<%= button_to "Delete", @employee, method: :delete,
    data: { turbo_confirm: "Are you sure?" } %>
<!-- app/views/employees/new.html.erb -->
<h1>New Employee</h1>
<%= render "form", employee: @employee %>
<%= link_to "Back to Employees", employees_path %>
<!-- app/views/employees/edit.html.erb -->
<h1>Edit Employee</h1>
<%= render "form", employee: @employee %>
<%= link_to "Back to Employees", employees_path %>
<!-- app/views/employees/_form.html.erb  (partial — ชื่อขึ้นต้นด้วย _) -->
<%= form_with(model: employee) do |form| %>
  <% if employee.errors.any? %>
    <div style="color: red;">
      <h3><%= pluralize(employee.errors.count, "error") %> prohibited this employee from being saved:</h3>
      <ul>
        <% employee.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div>
    <%= form.label :position %>
    <%= form.text_field :position %>
  </div>

  <div>
    <%= form.label :salary %>
    <%= form.number_field :salary, step: 1000 %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

อธิบาย ERB syntax:

Step 8 — เพิ่ม Validation ให้ Model

# app/models/employee.rb
class Employee < ApplicationRecord
  validates :name, presence: true
  validates :salary, presence: true, numericality: { greater_than: 0 }
  validates :position, presence: true
end

ลองใน console:

rails c

emp = Employee.new(name: "Test", salary: 0, position: "Dev")
emp.valid?        # => false
emp.errors.full_messages
# => ["Salary must be greater than 0"]

emp = Employee.new(salary: 30000)
emp.valid?        # => false
emp.errors.full_messages
# => ["Name can't be blank", "Position can't be blank"]

emp.save          # => false (ไม่บันทึกเพราะ invalid)

ตอนนี้ถ้า user submit form โดยไม่กรอกชื่อ หรือใส่เงินเดือน 0 — Rails จะ render form กลับมาพร้อมแสดง error messages อัตโนมัติ

Step 9 — รัน!

rails server

เปิด http://localhost:3000 — ได้ระบบจัดการพนักงานพร้อม CRUD เต็มรูปแบบ:

Step 10 — เพิ่มระบบลงเวลา (Associations)

rails generate model Attendance employee:references check_in:datetime check_out:datetime
rails db:migrate
# app/models/attendance.rb (generate มาให้แล้ว)
class Attendance < ApplicationRecord
  belongs_to :employee

  validates :check_in, presence: true

  # จำนวนชั่วโมงที่ทำงาน = check_out - check_in (แปลงเป็นชั่วโมง)
  def hours_worked
    return 0 unless check_in && check_out
    ((check_out - check_in) / 1.hour).round(2)
  end

  # ถ้าทำงานเกิน 8 ชม. → ส่วนเกินคือ OT
  def ot_hours
    [ hours_worked - 8, 0 ].max.round(2)
  end

  # custom validation = เขียน method ตรวจสอบเอง
  # ใช้เมื่อ built-in validates ทำไม่ได้
  validate :check_out_after_check_in
  validate :one_attendance_per_day

  private

  # Rule: check out ต้องหลัง check in
  def check_out_after_check_in
    return unless check_in && check_out

    if check_out <= check_in
      errors.add(:check_out, "must be after check in")
    end
  end

  # Rule: พนักงาน 1 คน เข้างานได้ 1 ครั้งต่อวัน
  def one_attendance_per_day
    return unless check_in && employee

    same_day = employee.attendances.where(check_in: check_in.all_day)
    same_day = same_day.where.not(id: id) if persisted?

    if same_day.exists?
      errors.add(:check_in, "already has attendance for this day")
    end
  end
end

สังเกต — Attendance มี custom validation 2 ตัว:

นี่คือพลังของ validate (ไม่มี s) — เขียน method ตรวจสอบเงื่อนไขอะไรก็ได้ที่ built-in validates ทำไม่ได้

# app/models/employee.rb — เพิ่ม association + payroll methods
class Employee < ApplicationRecord
  has_many :attendances, dependent: :destroy   # ลบพนักงาน → ลบ attendance ด้วย

  validates :name, presence: true
  validates :salary, presence: true, numericality: { greater_than: 0 }
  validates :position, presence: true

  # --- Payroll Methods ---
  # ทุก method รับ month, year เพื่อคำนวณเฉพาะเดือนนั้น

  # จำนวนวันที่มาทำงานในเดือนนั้น
  def working_days(month, year)
    monthly_attendances(month, year).count
  end

  # รวม OT ทั้งเดือน (ชม.)
  def total_ot_hours(month, year)
    monthly_attendances(month, year).sum(&:ot_hours)
  end

  # OT Pay = OT hours × rate ต่อชม.
  # rate = เงินเดือน / 30 วัน / 8 ชม.
  def ot_pay(month, year)
    hourly_rate = salary / 30.0 / 8.0
    total_ot_hours(month, year) * hourly_rate
  end

  # ภาษีแบบขั้นบันได (progressive tax)
  # ≤ 30,000       → 0%
  # 30,001-50,000  → 5%
  # > 50,000       → 10%
  #
  # ตัวอย่าง: เงินเดือน 54,000
  # - 0-30,000:      0%  → 0
  # - 30,001-50,000: 5%  → 20,000 × 0.05 = 1,000
  # - 50,001-54,000: 10% → 4,000 × 0.10  = 400
  # รวมภาษี = 1,400
  def tax
    return 0 if salary <= 30_000

    if salary > 50_000
      (salary - 50_000) * 0.10 + 20_000 * 0.05
    else
      (salary - 30_000) * 0.05
    end
  end

  # Net Pay = เงินเดือน + OT Pay - ภาษี
  def net_pay(month, year)
    salary + ot_pay(month, year) - tax
  end

  private

  # helper: ดึง attendance records ของเดือนนั้น
  def monthly_attendances(month, year)
    start_date = Date.new(year, month, 1)
    end_date = start_date.end_of_month
    attendances.where(check_in: start_date.beginning_of_day..end_date.end_of_day)
  end
end

นี่คือจุดที่ Rails เริ่มน่าสนใจ — Model ไม่ใช่แค่เก็บข้อมูล แต่มี business logic จริงๆ: คำนวณ OT, ภาษีขั้นบันได, Net Pay ลอง console ดู:

rails c

emp = Employee.find_by(name: "Somchai")  # salary: 30,000

# สมมุติมี 2 วันลงเวลา
emp.working_days(3, 2026)   # => 2
emp.total_ot_hours(3, 2026) # => 4.0
emp.ot_pay(3, 2026)         # => 500.0  (125 บาท/ชม. × 4 ชม.)
emp.tax                      # => 0     (เงินเดือน ≤ 30,000)
emp.net_pay(3, 2026)         # => 30500 (30,000 + 500 - 0)
# config/routes.rb — nested resource
Rails.application.routes.draw do
  root "employees#index"
  resources :employees do
    resources :attendances, only: [:new, :create, :edit, :update, :destroy]
  end
end
rails generate controller Attendances new create edit update destroy
# app/controllers/attendances_controller.rb
class AttendancesController < ApplicationController
  before_action :set_employee
  before_action :set_attendance, only: %i[ edit update destroy ]

  def new
    @attendance = @employee.attendances.new
  end

  def create
    @attendance = @employee.attendances.new(attendance_params)

    if @attendance.save
      redirect_to @employee, notice: "Attendance recorded."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
  end

  def update
    if @attendance.update(attendance_params)
      redirect_to @employee, notice: "Attendance updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @attendance.destroy!
    redirect_to @employee, notice: "Attendance deleted.", status: :see_other
  end

  private

  # ดึง employee จาก URL (params[:employee_id])
  def set_employee
    @employee = Employee.find(params[:employee_id])
  end

  # ดึง attendance จาก URL (params[:id])
  # ค้นผ่าน @employee.attendances เพื่อป้องกันเข้าถึง attendance ของคนอื่น
  def set_attendance
    @attendance = @employee.attendances.find(params[:id])
  end

  # Strong Parameters: อนุญาตเฉพาะ field ที่กำหนด
  def attendance_params
    params.require(:attendance).permit(:check_in, :check_out)
  end
end

เพิ่ม attendance section ใน employee show page:

<!-- เพิ่มท้าย app/views/employees/show.html.erb -->

<h3>Attendance Records</h3>

<%= link_to "Add Attendance", new_employee_attendance_path(@employee) %>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Check In</th>
      <th>Check Out</th>
      <th>Hours</th>
      <th>OT</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <% @employee.attendances.order(check_in: :desc).each do |att| %>
      <tr>
        <td><%= att.check_in.strftime("%Y-%m-%d") %></td>
        <td><%= att.check_in.strftime("%H:%M") %></td>
        <td><%= att.check_out&.strftime("%H:%M") || "-" %></td>
        <td><%= att.hours_worked %> hrs</td>
        <td><%= att.ot_hours %> hrs</td>
        <td>
          <%= link_to "Edit", edit_employee_attendance_path(@employee, att) %>
          <%= button_to "Delete", employee_attendance_path(@employee, att),
              method: :delete, data: { turbo_confirm: "Delete this record?" } %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

Bonus — คำสั่งที่ใช้บ่อย

# Server
rails server              # รัน server (port 3000)
rails s -p 4000           # รัน server port อื่น

# Console
rails console             # interactive shell
rails c --sandbox         # sandbox mode — rollback ทุกอย่างตอน exit

# Generate
rails g model User name:string email:string
rails g controller Pages home about
rails g migration AddDepartmentToEmployees department:string

# Database
rails db:migrate           # รัน pending migrations
rails db:rollback          # undo migration ล่าสุด
rails db:seed              # รัน db/seeds.rb
rails db:reset             # drop + create + migrate + seed

# Routes
rails routes               # ดู routes ทั้งหมด
rails routes -g employee   # filter เฉพาะที่มีคำว่า employee

# Tests
rails test                 # รัน test ทั้งหมด
rails test test/models/    # รัน test เฉพาะ folder

# อื่นๆ
rails -T                   # ดู task ทั้งหมดที่มี
rails about                # ดู version ของ Rails, Ruby, database

Scaffold — ทางลัดสำหรับคนขี้เกียจ (หรือคนฉลาด)

ทุกอย่างที่เราทำใน Step 3-9 สามารถทำได้ด้วย คำสั่งเดียว:

rails generate scaffold Employee name:string salary:decimal position:string
rails db:migrate
rails server

scaffold generate ให้ครบ: Model, Controller (ทุก action), Views (ทุก page), Migration, Routes, Tests — 3 คำสั่ง ได้ CRUD เต็มรูปแบบ

แต่การเขียน step by step ทำให้เข้าใจว่าแต่ละส่วนทำอะไร ก่อนจะใช้ shortcut

MVC — หัวใจของ Rails

Model — ข้อมูลและ business logic

# app/models/employee.rb
class Employee < ApplicationRecord
  # Associations — ความสัมพันธ์ระหว่าง model
  has_many :attendances, dependent: :destroy

  # Validations — ตรวจสอบข้อมูลก่อนบันทึก
  validates :name, presence: true
  validates :salary, presence: true, numericality: { greater_than: 0 }
  validates :position, presence: true

  # Scopes — query ที่ใช้บ่อย ตั้งชื่อให้
  scope :managers, -> { where(position: "Manager") }
  scope :high_salary, -> { where("salary > ?", 50000) }

  # Business logic — คำนวณ payroll
  def tax
    return 0 if salary <= 30_000
    if salary > 50_000
      (salary - 50_000) * 0.10 + 20_000 * 0.05
    else
      (salary - 30_000) * 0.05
    end
  end

  def net_pay(month, year)
    salary + ot_pay(month, year) - tax
  end
end

Active Record — ORM ของ Rails

# สร้าง
emp = Employee.create(name: "Somchai", salary: 30000, position: "Developer")

# อ่าน
emp = Employee.find(1)
managers = Employee.managers.high_salary
devs = Employee.where(position: "Developer").order(:name)

# อัปเดต
emp.update(salary: 35000)

# ลบ
emp.destroy

# Query ซับซ้อน — ยังอ่านง่าย
Employee.joins(:attendances)
    .where(attendances: { check_in: Date.today.all_day })
    .distinct
    .pluck(:name, :position)

ไม่ต้องเขียน SQL เลย — Active Record แปลง Ruby method chain เป็น SQL ให้

Controller — จัดการ request/response

# app/controllers/employees_controller.rb
class EmployeesController < ApplicationController
  before_action :set_employee, only: [:show, :edit, :update, :destroy]

  def index
    @employees = Employee.all
  end

  def show
    # @employee ถูก set แล้วจาก before_action
  end

  def create
    @employee = Employee.new(employee_params)

    if @employee.save
      redirect_to @employee, notice: "Employee was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @employee.update(employee_params)
      redirect_to @employee, notice: "Employee was successfully updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @employee.destroy
    redirect_to employees_url, notice: "Employee was successfully destroyed."
  end

  private

  def set_employee
    @employee = Employee.find(params[:id])
  end

  # Strong Parameters — ป้องกัน mass assignment attack
  def employee_params
    params.require(:employee).permit(:name, :salary, :position)
  end
end

View — แสดงผล

<!-- app/views/employees/index.html.erb -->
<h1>Employees</h1>

<% @employees.each do |employee| %>
  <article>
    <h2><%= link_to employee.name, employee %></h2>
    <p><%= employee.position %><%= number_to_currency(employee.salary, unit: "฿") %></p>
  </article>
<% end %>

<%= link_to "New Employee", new_employee_path %>

Routes — URL mapping

# config/routes.rb
Rails.application.routes.draw do
  root "employees#index"
  resources :employees do
    resources :attendances, only: [:new, :create, :edit, :update, :destroy]
  end

  # resources :employees สร้าง 7 routes ให้อัตโนมัติ:
  # GET    /employees          → employees#index
  # GET    /employees/new      → employees#new
  # POST   /employees          → employees#create
  # GET    /employees/:id      → employees#show
  # GET    /employees/:id/edit → employees#edit
  # PATCH  /employees/:id      → employees#update
  # DELETE /employees/:id      → employees#destroy
end

Migration — version control สำหรับ database

# db/migrate/20260309134857_create_employees.rb
class CreateEmployees < ActiveRecord::Migration[8.1]
  def change
    create_table :employees do |t|
      t.string :name
      t.decimal :salary
      t.string :position
      t.timestamps  # สร้าง created_at และ updated_at ให้
    end
  end
end
# db/migrate/20260309135217_create_attendances.rb
class CreateAttendances < ActiveRecord::Migration[8.1]
  def change
    create_table :attendances do |t|
      t.references :employee, null: false, foreign_key: true
      t.datetime :check_in
      t.datetime :check_out
      t.timestamps
    end
  end
end

Migration ทำให้:


Part 2 — ของดีๆ ที่มากับ Rails

สิ่งที่ framework อื่นต้องหาเอง แต่ Rails มีให้

Action Mailer — ส่ง email

class PayrollMailer < ApplicationMailer
  def payslip_email(employee, month, year)
    @employee = employee
    @month = month
    @year = year
    mail(to: @employee.email, subject: "Payslip for #{month}/#{year}")
  end
end

# เรียกใช้
PayrollMailer.payslip_email(employee, 3, 2026).deliver_later

Active Job — background processing

class CalculatePayrollJob < ApplicationJob
  queue_as :default

  def perform(month, year)
    Employee.find_each do |employee|
      PayrollMailer.payslip_email(employee, month, year).deliver_later
    end
  end
end

# เรียกใช้
CalculatePayrollJob.perform_later(3, 2026)

Action Cable — WebSocket

class AttendanceChannel < ApplicationCable::Channel
  def subscribed
    stream_from "attendance_updates"
  end
end

# broadcast เมื่อมีคน check in
ActionCable.server.broadcast("attendance_updates", {
  employee: employee.name,
  action: "checked_in",
  time: Time.current
})

Active Storage — file upload

class Employee < ApplicationRecord
  has_one_attached :profile_photo
end

# ใน controller
employee.profile_photo.attach(params[:profile_photo])

Turbo + Hotwire — SPA-like experience ไม่ต้องเขียน JavaScript

Rails 7+ มาพร้อม Hotwire ที่ทำให้หน้าเว็บรู้สึกเร็วเหมือน SPA โดยไม่ต้องเขียน JavaScript framework แยก

<!-- Turbo Frame — อัปเดตแค่ส่วนที่ต้องการ ไม่ต้อง reload ทั้งหน้า -->
<%= turbo_frame_tag "attendance_list" do %>
  <%= render @employee.attendances %>
<% end %>

Testing — มากับ Rails ตั้งแต่วันแรก

Rails ให้ความสำคัญกับ test มาก — rails new สร้าง folder test/ มาให้เลย ไม่ต้อง setup เอง ไม่ต้องเลือก test framework ไม่ต้อง config อะไร

test/
├── models/           # unit test — test model logic
├── controllers/      # test controller behavior
├── integration/      # test หลาย request ต่อกัน
├── system/           # test แบบเปิด browser จริง (Capybara + Selenium)
├── helpers/           # test helper methods
├── mailers/           # test email
├── fixtures/          # test data (เหมือน seed แต่สำหรับ test)
└── test_helper.rb     # config กลาง

Model Test — test logic ของ model

# test/models/employee_test.rb
require "test_helper"

class EmployeeTest < ActiveSupport::TestCase
  test "valid employee" do
    emp = Employee.new(name: "Test", salary: 30000, position: "Dev")
    assert emp.valid?
  end

  test "invalid without name" do
    emp = Employee.new(salary: 30000, position: "Dev")
    assert_not emp.valid?
  end

  test "invalid with zero salary" do
    emp = Employee.new(name: "Test", salary: 0, position: "Dev")
    assert_not emp.valid?
  end

  # --- ภาษีขั้นบันได ---

  test "tax 0 for salary 30000" do
    assert_equal 0, employees(:somchai).tax
  end

  test "tax for salary 54000" do
    # (5%*20,000) + (10%*4,000) = 1,400
    assert_equal 1400, employees(:somsri).tax
  end

  test "tax for salary 40000" do
    emp = Employee.new(name: "Mid", salary: 40000, position: "Dev")
    # (40,000 - 30,000) × 5% = 500
    assert_equal 500, emp.tax
  end

  # --- OT & Payroll ---

  test "working_days counts attendances in month" do
    # somchai มี 2 records ใน March 2026
    assert_equal 2, employees(:somchai).working_days(3, 2026)
  end

  test "total_ot_hours" do
    # somchai: day1 = 9h (1h OT), day2 = 11h (3h OT) → 4h
    assert_equal 4, employees(:somchai).total_ot_hours(3, 2026)
  end

  test "net_pay calculation" do
    # somchai: 30000 + 500 - 0 = 30500
    assert_equal 30500, employees(:somchai).net_pay(3, 2026)
  end
end

สังเกตว่า test ของ payroll มันสนุก — ไม่ใช่แค่ test ว่า save ได้/ไม่ได้ แต่ test business logic จริงๆ: ภาษีขั้นบันได, OT hours, Net Pay

Model Test — test Attendance

# test/models/attendance_test.rb
require "test_helper"

class AttendanceTest < ActiveSupport::TestCase
  test "hours_worked 8 hours" do
    att = Attendance.new(
      check_in: Time.zone.parse("2026-03-05 09:00"),
      check_out: Time.zone.parse("2026-03-05 17:00")
    )
    assert_equal 8.0, att.hours_worked
  end

  test "ot_hours 3 for 11h day" do
    att = Attendance.new(
      check_in: Time.zone.parse("2026-03-05 09:00"),
      check_out: Time.zone.parse("2026-03-05 20:00")
    )
    assert_equal 3.0, att.ot_hours
  end

  test "check_out must be after check_in" do
    att = Attendance.new(
      employee: employees(:junior),
      check_in: Time.zone.parse("2026-03-05 17:00"),
      check_out: Time.zone.parse("2026-03-05 09:00")
    )
    assert_not att.valid?
    assert_includes att.errors[:check_out], "must be after check in"
  end

  test "one attendance per day" do
    # somchai มี attendance วันที่ 2 March แล้ว (จาก fixture)
    att = Attendance.new(
      employee: employees(:somchai),
      check_in: Time.zone.parse("2026-03-02 08:00"),
      check_out: Time.zone.parse("2026-03-02 16:00")
    )
    assert_not att.valid?
    assert_includes att.errors[:check_in], "already has attendance for this day"
  end
end

Controller Test — test ว่า request ทำงานถูกต้อง

# test/controllers/employees_controller_test.rb
require "test_helper"

class EmployeesControllerTest < ActionDispatch::IntegrationTest
  test "should get index" do
    get employees_url
    assert_response :success    # ได้ status 200
  end

  test "should create employee" do
    assert_difference("Employee.count", 1) do
      post employees_url, params: {
        employee: { name: "New Dev", salary: 30000, position: "Developer" }
      }
    end
    assert_redirected_to employee_url(Employee.last)
  end

  test "should not create employee with invalid data" do
    assert_no_difference("Employee.count") do
      post employees_url, params: {
        employee: { name: "", salary: 0, position: "" }     # validation fail
      }
    end
    assert_response :unprocessable_entity
  end

  test "should destroy employee" do
    assert_difference("Employee.count", -1) do
      delete employee_url(employees(:one))    # :one มาจาก fixtures
    end
    assert_redirected_to employees_url
  end
end

System Test — test แบบเปิด browser จริง

# test/system/employees_test.rb
require "application_system_test_case"

class EmployeesTest < ApplicationSystemTestCase
  test "creating an employee" do
    visit employees_url
    click_on "New Employee"

    fill_in "Name", with: "New Developer"
    fill_in "Position", with: "Developer"
    fill_in "Salary", with: 35000
    click_on "Create Employee"

    assert_text "Employee was successfully created"
    assert_text "New Developer"
  end

  test "should show validation errors" do
    visit new_employee_url
    click_on "Create Employee"    # submit ฟอร์มเปล่า

    assert_text "Name can't be blank"
  end
end

System test เปิด browser จริง (Chrome headless) กดปุ่ม กรอกฟอร์ม เหมือน user ใช้งานจริง

Fixtures — test data

# test/fixtures/employees.yml
# เรียกใช้ใน test ด้วย employees(:somchai)

somchai:
  name: Somchai
  salary: 30000
  position: Developer

somsri:
  name: Somsri
  salary: 54000
  position: Manager

junior:
  name: Junior
  salary: 20000
  position: Intern
# test/fixtures/attendances.yml
# somchai: วันละ 9 ชม. (1h OT) และ 11 ชม. (3h OT)
somchai_day1:
  employee: somchai
  check_in: "2026-03-02 09:00:00"
  check_out: "2026-03-02 18:00:00"

somchai_day2:
  employee: somchai
  check_in: "2026-03-03 09:00:00"
  check_out: "2026-03-03 20:00:00"

# somsri: วันละ 9 ชม. (1h OT)
somsri_day1:
  employee: somsri
  check_in: "2026-03-02 08:00:00"
  check_out: "2026-03-02 17:00:00"

Fixtures โหลดเข้า test database อัตโนมัติก่อนรัน test ทุกครั้ง — ไม่ต้องสร้าง data ซ้ำ

สังเกต — fixture ของ attendance ถูกออกแบบมาให้คำนวณ OT ได้:

รัน test

rails test                       # รัน test ทั้งหมด
rails test test/models/          # รัน model test เท่านั้น
rails test test/models/employee_test.rb          # รัน ไฟล์เดียว
rails test test/models/employee_test.rb:10       # รัน test บรรทัดที่ 10

rails test:system                # รัน system test (เปิด browser)

เบื้องหลัง test ใน Rails ใช้อะไร?

Test ใน Rails ไม่ได้เป็น library ก้อนเดียว — มันประกอบจากหลายชิ้นที่ทำงานร่วมกัน:

rails test

    ├── Minitest ─────────── test framework หลัก (เหมือน Jest)
    │   ├── assertions:      assert, assert_equal, assert_not, assert_nil...
    │   ├── test runner:      รัน test, รายงานผล, จัดลำดับ
    │   └── มากับ Ruby:       ไม่ต้อง install เพิ่ม

    ├── ActiveSupport::TestCase ── Rails wrapper ครอบ Minitest
    │   ├── เพิ่ม:            fixtures loading อัตโนมัติ
    │   ├── เพิ่ม:            database transaction (rollback หลัง test จบ)
    │   └── เพิ่ม:            Rails-specific assertions

    ├── ActionDispatch::IntegrationTest ── สำหรับ controller/request test
    │   ├── ทำ HTTP request:  get, post, patch, delete
    │   ├── ตรวจ response:    assert_response, assert_redirected_to
    │   └── เบื้องหลัง:       Rack::Test (จำลอง HTTP request ไม่ต้องเปิด server จริง)

    ├── Capybara ─────────── สำหรับ system test (เปิด browser จริง)
    │   ├── DSL:              visit, fill_in, click_on, assert_text
    │   ├── เบื้องหลัง:       สั่ง browser ผ่าน WebDriver protocol
    │   └── เหมือน:           Cypress / Playwright แต่เขียนเป็น Ruby

    └── Selenium WebDriver ── ตัวขับ browser จริง
        ├── เปิด:             Chrome headless (ไม่เห็นหน้าจอ)
        └── ทำ:               กดปุ่ม, กรอกฟอร์ม, scroll, screenshot

เปรียบเทียบกับ JS ecosystem:

Rails testing stack:          JS testing stack:

Minitest                      Jest / Vitest
  (test framework)              (test framework)
       +                             +
ActiveSupport::TestCase       (ไม่มี — ต้อง config เอง)
  (Rails integration)
       +                             +
Rack::Test                    Supertest / MSW
  (จำลอง HTTP request)          (จำลอง HTTP request)
       +                             +
Capybara                      Cypress / Playwright
  (browser automation DSL)      (browser automation)
       +                             +
Selenium                      Chromium (built-in)
  (browser driver)              (browser driver)

ความต่าง:

แล้ว test database ล่ะ?

Development:  payroll_app_development  ← ใช้ตอน rails server
Test:         payroll_app_test         ← ใช้ตอน rails test (คนละ database)
Production:   payroll_app_production   ← ใช้ตอน deploy

ตอนรัน test:

  1. Rails ใช้ database _test แยกจาก development — data ไม่ปนกัน
  2. แต่ละ test ถูกครอบด้วย database transaction
  3. test จบ → rollback ทุกอย่าง → database กลับเป็นสถานะเดิม
  4. test ถัดไปเริ่มจาก state ที่สะอาด → test ไม่กระทบกัน
# เบื้องหลัง (Rails ทำให้อัตโนมัติ ไม่ต้องเขียนเอง):
BEGIN TRANSACTION
  # ... รัน test ...
  # สร้าง employee, attendance อะไรก็ตาม
ROLLBACK
# database กลับเป็นเหมือนเดิม ไม่มี data ค้าง

เทียบกับ JS testing

JavaScript (React)Rails
Unit testJest / VitestMinitest (มาพร้อม Rails)
Component testReact Testing LibraryController test (Rack::Test)
E2E / Browser testCypress / PlaywrightSystem test (Capybara + Selenium)
HTTP mockingMSW / nockRack::Test (ไม่ต้อง mock, จำลอง request ตรง)
Test dataFactory / mock เองFixtures (มาพร้อม Rails)
DB cleanupต้อง setup เองTransaction rollback อัตโนมัติ
Setup ครั้งแรกinstall + config หลาย packageไม่ต้อง setup มีมาให้ครบ
รันnpm testrails test

หัวใจ: Rails community เชื่อว่า “test ที่ดี > type annotation ที่ดี” เพราะ test บอกได้ว่า code ทำงานถูกต้อง ไม่ใช่แค่ type ถูกต้อง

RSpec — alternative ที่นิยม

Minitest มากับ Rails แต่หลายทีมเลือกใช้ RSpec แทน เพราะอ่านง่ายกว่า:

# Minitest style
test "tax 0 for salary 30000" do
  assert_equal 0, employees(:somchai).tax
end

# RSpec style — อ่านเหมือนภาษาอังกฤษ
describe Employee do
  it "has 0 tax for salary 30000" do
    expect(employees(:somchai).tax).to eq 0
  end
end

ทั้งคู่ทำได้เหมือนกัน เลือกตามที่ชอบ — Minitest (simple, มาพร้อม Rails) vs RSpec (expressive, ต้องติดตั้งเพิ่ม)


Part 3 — Authentication: ระบบ Login ด้วย Devise

Devise คืออะไร?

Devise คือ gem สำหรับ authentication ที่เป็น de facto standard ของ Rails — ทุกคนใช้ แทบไม่มีใครเขียน auth เอง

เทียบกับ ecosystem อื่น:

FrameworkAuth Solution
Express.jsPassport.js (ต้อง config เอง)
Djangodjango.contrib.auth (มาพร้อม Django)
LaravelLaravel Breeze / Fortify
RailsDevise (gem แยก แต่เป็น standard)
Next.jsNextAuth.js / Clerk

ติดตั้งและ Setup

# 1. เพิ่ม gem
bundle add devise

# 2. install Devise
rails generate devise:install
# สร้าง config/initializers/devise.rb + instructions

# 3. generate User model
rails generate devise User
# สร้าง migration + model ที่มี email, encrypted_password, etc.

# 4. run migration
rails db:migrate

แค่ 4 คำสั่ง — ได้ระบบ auth ครบ:

ใช้ใน Controller

class EmployeesController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  # ↑ ต้อง login ก่อนถึงจะ create/edit/delete ได้

  def create
    @employee = Employee.new(employee_params)
    # current_user = user ที่ login อยู่ (Devise ให้มา)
    ...
  end
end

ใช้ใน View

<% if user_signed_in? %>
  <p>สวัสดี, <%= current_user.email %></p>
  <%= link_to "Logout", destroy_user_session_path, data: { turbo_method: :delete } %>
<% else %>
  <%= link_to "Login", new_user_session_path %>
  <%= link_to "Sign up", new_user_registration_path %>
<% end %>

helper ที่ Devise ให้มา:

Customize หน้า Login

Devise สร้าง view ให้อัตโนมัติ แต่ถ้าอยากแก้:

rails generate devise:views
# สร้าง app/views/devise/ ให้แก้ได้:
# - registrations/new.html.erb  (sign up)
# - sessions/new.html.erb       (login)
# - passwords/new.html.erb      (forgot password)

เพิ่ม field ให้ User (เช่น name, role)

rails generate migration AddNameToUsers name:string
rails db:migrate
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
    devise_parameter_sanitizer.permit(:account_update, keys: [:name])
  end
end

Rails 8 Alternative: Authentication Generator

Rails 8 มี built-in authentication generator ที่ไม่ต้องใช้ Devise:

rails generate authentication

สร้าง User model + Session model + controller + views ให้ครบ — เหมาะกับคนที่อยากเข้าใจว่า auth ทำงานยังไงทุก step

เลือกยังไง:


Part 4 — API Mode: Rails เป็น Backend อย่างเดียว

เมื่อไหร่ควรใช้ API Mode?

ถ้า frontend เป็น React / Vue / Svelte / mobile app — ไม่ต้องการ View layer ของ Rails เลย ใช้ API mode:

rails new payroll_api --api

ต่างจาก Rails ปกติยังไง:

Rails ปกติRails API
View layerERB templatesไม่มี (ส่ง JSON)
Session/Cookieมีไม่มี (ใช้ token)
CSRF protectionมีไม่มี
Middlewareครบเบาลง ~30%
ResponseHTMLJSON

ตัวอย่าง Controller

# app/controllers/api/v1/employees_controller.rb
module Api
  module V1
    class EmployeesController < ApplicationController
      def index
        @employees = Employee.all
        render json: @employees
      end

      def show
        @employee = Employee.find(params[:id])
        render json: @employee, include: [:attendances]
      end

      def create
        @employee = Employee.new(employee_params)

        if @employee.save
          render json: @employee, status: :created
        else
          render json: { errors: @employee.errors }, status: :unprocessable_entity
        end
      end
    end
  end
end

Routes

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :employees do
        resources :attendances, only: [:create, :destroy]
      end
    end
  end
end
# → GET /api/v1/employees, POST /api/v1/employees, etc.

JSON Serialization

Rails มีหลายวิธีส่ง JSON:

# 1. ง่ายสุด — render json ตรงๆ
render json: @employee

# 2. เลือก field — as_json
render json: @employee.as_json(
  only: [:id, :name, :salary, :position],
  include: :attendances,
  methods: [:tax]   # เรียก method tax แล้วใส่ใน JSON ด้วย
)

# 3. ใช้ Serializer (แนะนำสำหรับ production)
# Gemfile: gem 'alba' หรือ gem 'blueprinter'
class EmployeeSerializer
  include Alba::Resource
  attributes :id, :name, :salary, :position
  attribute :tax do |employee|
    employee.tax
  end
  has_many :attendances, resource: AttendanceSerializer
end

render json: EmployeeSerializer.new(@employees)

Authentication สำหรับ API

API ไม่ใช้ cookie/session — ใช้ token แทน:

# Simple token auth
class ApplicationController < ActionController::API
  before_action :authenticate_token!

  private

  def authenticate_token!
    token = request.headers["Authorization"]&.split(" ")&.last
    @current_user = User.find_by(api_token: token)
    render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
  end
end

production จริงแนะนำ JWT (gem jwt) หรือ Doorkeeper (OAuth2 provider)

เมื่อไหร่ควรใช้ API mode vs ปกติ

ใช้ API mode เมื่อใช้ Rails ปกติ เมื่อ
Frontend เป็น React/Vue/Svelteใช้ Hotwire + ERB
Mobile app เป็น client หลักWeb app เป็น client หลัก
ทีม frontend แยกจาก backendFull-stack developer / ทีมเล็ก
ต้อง serve หลาย client (web + mobile + 3rd party)Client เดียว

ครบ 3 Part แล้ว! กลับไปอ่าน: Part 1: ทำไมต้อง Rails · Part 2: Rails ทำงานยังไง