├── log └── .keep ├── tmp ├── .keep └── pdfs │ ├── .keep │ ├── source │ └── .keep │ └── encrypted │ └── .keep ├── vendor └── .keep ├── lib ├── assets │ └── .keep ├── tasks │ ├── .keep │ ├── sample.csv │ ├── import.rake │ └── dev.rake └── templates │ └── haml │ └── scaffold │ └── _form.html.haml ├── public ├── favicon.ico ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png └── robots.txt ├── test ├── helpers │ ├── .keep │ └── payrolls_test.rb ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── overtime_test.rb │ ├── term_test.rb │ ├── extra_entry_test.rb │ └── statement_test.rb ├── system │ └── .keep ├── integration │ └── .keep ├── factories │ ├── terms.rb │ ├── overtime.rb │ ├── statement.rb │ ├── correction.rb │ ├── extra_entry.rb │ ├── salary.rb │ ├── payroll.rb │ └── employee.rb ├── application_system_test_case.rb ├── services │ ├── payroll_init_service_test.rb │ ├── income_tax_service │ │ ├── insuranced_salary_test.rb │ │ ├── regular_employee_test.rb │ │ ├── exemption_test.rb │ │ ├── irregular_income_test.rb │ │ ├── professional_service_test.rb │ │ ├── uninsuranced_salary_test.rb │ │ └── dispatcher_test.rb │ ├── health_insurance_service │ │ ├── supplement_premium_rate_test.rb │ │ ├── company_withhold_bonus_test.rb │ │ ├── parttime_income_test.rb │ │ ├── company_coverage_test.rb │ │ ├── professional_service_test.rb │ │ └── dispatcher_test.rb │ ├── business_calendar_days_serivce_test.rb │ ├── calculation_service │ │ ├── sicktime_test.rb │ │ ├── vacation_refund_test.rb │ │ └── leavetime_test.rb │ ├── payroll_init_regulars_service_test.rb │ ├── salary_service │ │ └── sync_payroll_relations_test.rb │ ├── minimun_wage_service_test.rb │ └── statement_service │ │ ├── splitter_test.rb │ │ └── builder_test.rb ├── test_helper.rb ├── decorators │ ├── employee_decorator_test.rb │ ├── payroll_decorator_test.rb │ ├── salary_decorator_test.rb │ └── statement_decorator_test.rb └── lib │ └── collection_translatable_test.rb ├── .ruby-version ├── app ├── assets │ ├── images │ │ └── .keep │ ├── stylesheets │ │ ├── terms.scss │ │ ├── reports.scss │ │ ├── employees.scss │ │ ├── statements.scss │ │ ├── salaries.scss │ │ ├── application.scss │ │ └── payrolls.scss │ ├── javascripts │ │ ├── channels │ │ │ └── .keep │ │ ├── employees.js │ │ ├── cable.js │ │ └── application.js │ └── config │ │ └── manifest.js ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── correction.rb │ ├── term.rb │ ├── extra_entry.rb │ ├── overtime.rb │ ├── payroll_detail.rb │ ├── statement.rb │ ├── employee.rb │ ├── salary_tracker.rb │ └── salary.rb ├── controllers │ ├── concerns │ │ └── .keep │ ├── application_controller.rb │ ├── terms_controller.rb │ ├── statements_controller.rb │ ├── salaries_controller.rb │ └── reports_controller.rb ├── views │ ├── layouts │ │ ├── mailer.text.erb │ │ ├── mailer.html.erb │ │ ├── pdf.pdf.haml │ │ └── application.html.erb │ ├── reports │ │ ├── index.haml │ │ ├── _title.haml │ │ ├── _cell.haml │ │ ├── _header.haml │ │ ├── irregular.haml │ │ ├── monthly.haml │ │ ├── service.haml │ │ └── salary.haml │ ├── employees │ │ ├── edit.haml │ │ ├── new.haml │ │ ├── show.haml │ │ ├── _info.haml │ │ ├── _terms.haml │ │ ├── _payrolls.haml │ │ ├── _salaries.haml │ │ ├── index.haml │ │ └── _form.haml │ ├── kaminari │ │ ├── _gap.html.haml │ │ ├── _first_page.html.haml │ │ ├── _last_page.html.haml │ │ ├── _next_page.html.haml │ │ ├── _prev_page.html.haml │ │ ├── _page.html.haml │ │ └── _paginator.html.haml │ ├── statements │ │ ├── index.json.jbuilder │ │ ├── _notes.haml │ │ ├── _correction_fields.haml │ │ ├── _details.haml │ │ ├── index.haml │ │ ├── edit.haml │ │ ├── _correction_form.haml │ │ ├── _list.haml │ │ ├── show.haml │ │ └── split.haml │ ├── terms │ │ ├── _info.haml │ │ ├── _form.haml │ │ ├── new.haml │ │ ├── edit.haml │ │ └── show.haml │ ├── statement_mailer │ │ └── notify_email.text.erb │ ├── payrolls │ │ ├── _festival_bonus_form.haml │ │ ├── _initiate_link.haml │ │ ├── _extra_entry_fields.haml │ │ ├── index.haml │ │ ├── _overtime_fields.haml │ │ ├── _basic_form.haml │ │ ├── _list.haml │ │ ├── _statements.haml │ │ ├── _overtime_form.haml │ │ ├── parttimers.haml │ │ ├── edit.haml │ │ └── _extra_entry_form.haml │ └── salaries │ │ ├── new.haml │ │ ├── edit.haml │ │ ├── show.haml │ │ ├── _info.haml │ │ ├── recent.js.erb │ │ └── _form.haml ├── helpers │ ├── application_helper.rb │ └── payrolls_helper.rb ├── decorators │ ├── employee_decorator.rb │ ├── payroll_decorator.rb │ ├── salary_decorator.rb │ └── statement_decorator.rb ├── mailers │ ├── application_mailer.rb │ └── statement_mailer.rb ├── services │ ├── callable.rb │ ├── salary_service │ │ ├── destroy.rb │ │ ├── update.rb │ │ ├── create.rb │ │ ├── sync_payroll_relations.rb │ │ ├── term_finder.rb │ │ └── base.rb │ ├── income_tax_service │ │ ├── regular_employee.rb │ │ ├── insuranced_salary.rb │ │ ├── professional_service.rb │ │ ├── exemption.rb │ │ ├── dispatcher.rb │ │ ├── irregular_income.rb │ │ └── uninsuranced_salary.rb │ ├── format_service │ │ ├── extra_income.rb │ │ ├── extra_deductions.rb │ │ ├── festival_bonus.rb │ │ ├── statement_gain_loss_columns.rb │ │ ├── statement_notes.rb │ │ └── deductions.rb │ ├── calculation_service │ │ ├── sicktime.rb │ │ ├── vacation_refund.rb │ │ ├── leavetime.rb │ │ ├── total_income.rb │ │ ├── total_deduction.rb │ │ └── overtime.rb │ ├── health_insurance_service │ │ ├── supplement_premium_rate.rb │ │ ├── company_withhold_bonus.rb │ │ ├── dispatcher.rb │ │ ├── professional_service.rb │ │ ├── parttime_income.rb │ │ ├── company_coverage.rb │ │ └── bonus_income.rb │ ├── payroll_init_regulars_service.rb │ ├── statement_service │ │ ├── update.rb │ │ ├── splitter.rb │ │ └── builder.rb │ ├── report_service │ │ ├── vacation_refund_cell.rb │ │ ├── overtime_cell.rb │ │ ├── service_income_cell.rb │ │ ├── extra_entry_cell.rb │ │ ├── service_income.rb │ │ ├── irregular_income.rb │ │ ├── monthly_statements.rb │ │ └── salary_income.rb │ ├── payroll_init_service.rb │ ├── minimum_wage_service.rb │ └── business_calendar_days_service.rb └── lib │ ├── collection_translatable.rb │ └── statement_pdf_builder.rb ├── package.json ├── config ├── initializers │ ├── ransack.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── generators.rb │ ├── filter_parameter_logging.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── kaminari_config.rb │ ├── backtrace_silencers.rb │ ├── wrap_parameters.rb │ ├── assets.rb │ └── wicked_pdf.rb ├── spring.rb ├── boot.rb ├── locales │ ├── reports.zh-TW.yml │ ├── simple_form.en.yml │ └── en.yml ├── environment.rb ├── cable.yml ├── application.yml.sample ├── holidays │ ├── 2015.yml │ ├── 2025.yml │ ├── 2022.yml │ ├── 2024.yml │ ├── 2018.yml │ ├── 2017.yml │ ├── 2020.yml │ ├── 2021.yml │ ├── 2016.yml │ ├── 2019.yml │ └── 2023.yml ├── application.rb ├── minimum_wage.yml ├── secrets.yml.sample └── routes.rb ├── bin ├── bundle ├── rake ├── rails ├── yarn ├── spring ├── update └── setup ├── config.ru ├── db ├── migrate │ ├── 20181009033016_create_reports.rb │ ├── 20181026081745_drop_reports.rb │ ├── 20181027094809_create_payroll_details.rb │ ├── 20200714082821_add_term_to_salary.rb │ ├── 20200716024001_create_salary_trackers.rb │ ├── 20181008070814_add_salary_to_payrolls.rb │ ├── 20181026081854_update_reports_to_version_7.rb │ ├── 20181017081148_add_b2b_to_employee.rb │ ├── 20190303142812_rename_income_tax.rb │ ├── 20200103074054_add_split_to_salary.rb │ ├── 20180413081120_add_cycle_to_salaries.rb │ ├── 20181027082411_add_owner_to_employees.rb │ ├── 20181010173602_add_index_to_payrolls.rb │ ├── 20190225063154_add_tax_income_to_salary.rb │ ├── 20181010080608_update_reports_to_version_2.rb │ ├── 20181011102527_update_reports_to_version_3.rb │ ├── 20181013142045_update_reports_to_version_4.rb │ ├── 20181013163043_update_reports_to_version_5.rb │ ├── 20181017081241_update_reports_to_version_6.rb │ ├── 20200703080637_update_reports_to_version_8.rb │ ├── 20200721073317_add_correct_to_statements.rb │ ├── 20200721093758_update_reports_to_version_9.rb │ ├── 20181026080817_change_irregular_to_subsidy.rb │ ├── 20181027154228_remove_taxable_from_extra_entries.rb │ ├── 20181018072141_add_bonus_income_to_statements.rb │ ├── 20181028134613_add_excess_income_to_statements.rb │ ├── 20181028142154_remove_bonus_income_from_statements.rb │ ├── 20181010071005_add_irregular_income_to_statements.rb │ ├── 20181026041233_add_income_type_to_extra_entries.rb │ ├── 20181028141152_update_payroll_details_to_version_2.rb │ ├── 20200703065915_add_detail_to_statement.rb │ ├── 20180412032053_add_bank_transfer_type_to_employees.rb │ ├── 20180604064006_add_monthly_wage_adjustment_to_salary.rb │ ├── 20181108055027_remove_dates_from_employee.rb │ ├── 20181013135559_add_festival_to_payrolls.rb │ ├── 20180413054837_add_insurance_to_salaries.rb │ ├── 20181011084858_create_corrections.rb │ ├── 20180106155844_create_overtimes.rb │ ├── 20181106120752_create_terms.rb │ ├── 20180114122028_create_statements.rb │ ├── 20180107080943_create_extra_entries.rb │ ├── 20180105170858_create_payrolls.rb │ ├── 20180103063747_create_employees.rb │ ├── 20181014044335_add_indexes.rb │ ├── 20180105160319_create_salaries.rb │ └── 20181119082055_change_float_to_decimal.rb ├── views │ ├── reports_v01.sql │ ├── salary_trackers_v01.sql │ ├── reports_v02.sql │ ├── payroll_details_v01.sql │ ├── payroll_details_v02.sql │ ├── reports_v03.sql │ ├── reports_v04.sql │ ├── reports_v05.sql │ ├── reports_v07.sql │ ├── reports_v06.sql │ ├── reports_v09.sql │ └── reports_v08.sql └── seeds.rb ├── Rakefile ├── .reek.yml ├── .gitignore ├── LICENSE.txt ├── Gemfile └── .github └── workflows └── rails.yml /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pdfs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.6 2 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pdfs/source/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pdfs/encrypted/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/stylesheets/terms.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/javascripts/employees.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/stylesheets/reports.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/reports/index.haml: -------------------------------------------------------------------------------- 1 | = render "header" 2 | -------------------------------------------------------------------------------- /app/assets/stylesheets/employees.scss: -------------------------------------------------------------------------------- 1 | .employees { 2 | } 3 | -------------------------------------------------------------------------------- /app/views/employees/edit.haml: -------------------------------------------------------------------------------- 1 | %h1 編輯員工資料 2 | = render "form", employee: @employee 3 | -------------------------------------------------------------------------------- /app/views/employees/new.haml: -------------------------------------------------------------------------------- 1 | %h1 新增員工資料 2 | = render "form", employee: @employee 3 | -------------------------------------------------------------------------------- /app/views/reports/_title.haml: -------------------------------------------------------------------------------- 1 | %h3 #{params[:year]} 年#{t(action_name, scope: :reports)} 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fortuna", 3 | "private": true, 4 | "dependencies": {} 5 | } 6 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ApplicationHelper 3 | end 4 | -------------------------------------------------------------------------------- /app/views/kaminari/_gap.html.haml: -------------------------------------------------------------------------------- 1 | %li.disabled 2 | = content_tag :a, raw(t 'views.pagination.truncate') 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /test/factories/terms.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | FactoryBot.define do 3 | factory :term do 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/statements.scss: -------------------------------------------------------------------------------- 1 | .statements#index { 2 | #statement_search { 3 | margin-bottom: 20px; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/factories/overtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :overtime do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/factories/statement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :statement do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/salaries.scss: -------------------------------------------------------------------------------- 1 | .salaries { 2 | .tip { 3 | border-bottom: 1px dashed #ccc; 4 | cursor: help; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /config/initializers/ransack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Ransack.configure do |config| 3 | config.search_key = :query 4 | end 5 | -------------------------------------------------------------------------------- /test/factories/correction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :correction do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/factories/extra_entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :extra_entry do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/kaminari/_first_page.html.haml: -------------------------------------------------------------------------------- 1 | %li 2 | = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, :remote => remote 3 | -------------------------------------------------------------------------------- /app/views/kaminari/_last_page.html.haml: -------------------------------------------------------------------------------- 1 | %li 2 | = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, {:remote => remote} 3 | -------------------------------------------------------------------------------- /app/views/statements/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | json.array! @statements, :id, :amount, :year, :month, :employee_id 3 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | %w( 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ).each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | -------------------------------------------------------------------------------- /app/views/statements/_notes.haml: -------------------------------------------------------------------------------- 1 | %tr 2 | %th.text-center 備註 3 | %td{colspan: 3} 4 | %ul 5 | - notes.each do |note| 6 | %li= note 7 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /test/factories/salary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :salary do 5 | role { "regular" } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/kaminari/_next_page.html.haml: -------------------------------------------------------------------------------- 1 | %li 2 | = link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, :rel => 'next', :remote => remote 3 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /db/migrate/20181009033016_create_reports.rb: -------------------------------------------------------------------------------- 1 | class CreateReports < ActiveRecord::Migration[5.1] 2 | def change 3 | create_view :reports 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/kaminari/_prev_page.html.haml: -------------------------------------------------------------------------------- 1 | %li 2 | = link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, :rel => 'prev', :remote => remote 3 | -------------------------------------------------------------------------------- /config/locales/reports.zh-TW.yml: -------------------------------------------------------------------------------- 1 | zh-TW: 2 | reports: 3 | salary: "薪資支出表" 4 | service: "勞務報酬支出表" 5 | irregular: "非經常性給予支出表" 6 | monthly: "每月薪資明細" 7 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/decorators/employee_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module EmployeeDecorator 3 | def role 4 | salaries.ordered.first.given_role 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20181026081745_drop_reports.rb: -------------------------------------------------------------------------------- 1 | class DropReports < ActiveRecord::Migration[5.2] 2 | def change 3 | drop_view :reports, revert_to_version: 6 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181027094809_create_payroll_details.rb: -------------------------------------------------------------------------------- 1 | class CreatePayrollDetails < ActiveRecord::Migration[5.2] 2 | def change 3 | create_view :payroll_details 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200714082821_add_term_to_salary.rb: -------------------------------------------------------------------------------- 1 | class AddTermToSalary < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :salaries, :term_id, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200716024001_create_salary_trackers.rb: -------------------------------------------------------------------------------- 1 | class CreateSalaryTrackers < ActiveRecord::Migration[5.2] 2 | def change 3 | create_view :salary_trackers 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class ApplicationMailer < ActionMailer::Base 3 | default from: ENV.fetch("email_sender") 4 | layout "mailer" 5 | end 6 | -------------------------------------------------------------------------------- /app/views/terms/_info.haml: -------------------------------------------------------------------------------- 1 | %h3 合作期間 2 | %table.table 3 | %tr 4 | %th.col-xs-1 起始日期 5 | %td.col-xs-2= term.start_date 6 | %th.col-xs-1 結束日期 7 | %td.col-xs-2= term.end_date 8 | -------------------------------------------------------------------------------- /db/migrate/20181008070814_add_salary_to_payrolls.rb: -------------------------------------------------------------------------------- 1 | class AddSalaryToPayrolls < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :payrolls, :salary_id, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181026081854_update_reports_to_version_7.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion7 < ActiveRecord::Migration[5.2] 2 | def change 3 | create_view :reports, version: 7 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | ActiveSupport::Inflector.inflections(:en) do |inflect| 2 | # inflect.singular /^(bonus)(es)?$/i, '\1' 3 | # inflect.plural /^(bonus)$/i, '\1us' 4 | end 5 | 6 | -------------------------------------------------------------------------------- /db/migrate/20181017081148_add_b2b_to_employee.rb: -------------------------------------------------------------------------------- 1 | class AddB2bToEmployee < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :employees, :b2b, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20190303142812_rename_income_tax.rb: -------------------------------------------------------------------------------- 1 | class RenameIncomeTax < ActiveRecord::Migration[5.2] 2 | def change 3 | rename_column :salaries, :income_tax, :fixed_income_tax 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /db/migrate/20200103074054_add_split_to_salary.rb: -------------------------------------------------------------------------------- 1 | class AddSplitToSalary < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :salaries, :split, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180413081120_add_cycle_to_salaries.rb: -------------------------------------------------------------------------------- 1 | class AddCycleToSalaries < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :salaries, :cycle, :string, default: "normal" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181027082411_add_owner_to_employees.rb: -------------------------------------------------------------------------------- 1 | class AddOwnerToEmployees < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :employees, :owner, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181010173602_add_index_to_payrolls.rb: -------------------------------------------------------------------------------- 1 | class AddIndexToPayrolls < ActiveRecord::Migration[5.1] 2 | def change 3 | add_index :payrolls, :year 4 | add_index :payrolls, :month 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20190225063154_add_tax_income_to_salary.rb: -------------------------------------------------------------------------------- 1 | class AddTaxIncomeToSalary < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :salaries, :income_tax, :integer, :default => 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | channel_prefix: fortuna_production 11 | -------------------------------------------------------------------------------- /db/migrate/20181010080608_update_reports_to_version_2.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion2 < ActiveRecord::Migration[5.1] 2 | def change 3 | update_view :reports, version: 2, revert_to_version: 1 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181011102527_update_reports_to_version_3.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion3 < ActiveRecord::Migration[5.1] 2 | def change 3 | update_view :reports, version: 3, revert_to_version: 2 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181013142045_update_reports_to_version_4.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion4 < ActiveRecord::Migration[5.1] 2 | def change 3 | update_view :reports, version: 4, revert_to_version: 3 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181013163043_update_reports_to_version_5.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion5 < ActiveRecord::Migration[5.1] 2 | def change 3 | update_view :reports, version: 5, revert_to_version: 4 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181017081241_update_reports_to_version_6.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion6 < ActiveRecord::Migration[5.2] 2 | def change 3 | update_view :reports, version: 6, revert_to_version: 5 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200703080637_update_reports_to_version_8.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion8 < ActiveRecord::Migration[5.2] 2 | def change 3 | update_view :reports, version: 8, revert_to_version: 7 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200721073317_add_correct_to_statements.rb: -------------------------------------------------------------------------------- 1 | class AddCorrectToStatements < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :statements, :correction, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200721093758_update_reports_to_version_9.rb: -------------------------------------------------------------------------------- 1 | class UpdateReportsToVersion9 < ActiveRecord::Migration[5.2] 2 | def change 3 | update_view :reports, version: 9, revert_to_version: 8 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/statements/_correction_fields.haml: -------------------------------------------------------------------------------- 1 | %tr.nested-fields 2 | %td= f.input_field :amount 3 | %td= f.input_field :description, size: 50 4 | %td= link_to_remove_association "移除", f, class: "btn btn-xs btn-danger" 5 | -------------------------------------------------------------------------------- /db/migrate/20181026080817_change_irregular_to_subsidy.rb: -------------------------------------------------------------------------------- 1 | class ChangeIrregularToSubsidy < ActiveRecord::Migration[5.2] 2 | def change 3 | rename_column :statements, :irregular_income, :subsidy_income 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181027154228_remove_taxable_from_extra_entries.rb: -------------------------------------------------------------------------------- 1 | class RemoveTaxableFromExtraEntries < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_column :extra_entries, :taxable, :boolean 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/services/callable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Callable 3 | extend ActiveSupport::Concern 4 | 5 | class_methods do 6 | def call(*args) 7 | new(*args).call 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/generators.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.generators do |g| 2 | g.template_engine :haml 3 | g.test_framework :test_unit, fixture: false 4 | g.fixture_replacement :factory_girl, dir: 'spec/factories' 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181018072141_add_bonus_income_to_statements.rb: -------------------------------------------------------------------------------- 1 | class AddBonusIncomeToStatements < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :statements, :bonus_income, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181028134613_add_excess_income_to_statements.rb: -------------------------------------------------------------------------------- 1 | class AddExcessIncomeToStatements < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :statements, :excess_income, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181028142154_remove_bonus_income_from_statements.rb: -------------------------------------------------------------------------------- 1 | class RemoveBonusIncomeFromStatements < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_column :statements, :bonus_income, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/correction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 由於本系統為動態計算,當計算公式因故有所調整時, 3 | # 會與既有會計紀錄(且會計端已無法修改)產生誤差, 4 | # 為讓系統報表與會計紀錄維持一致,可透過此 model 做手動校正 5 | class Correction < ApplicationRecord 6 | belongs_to :statement 7 | end 8 | -------------------------------------------------------------------------------- /app/views/statement_mailer/notify_email.text.erb: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | 感謝大家這個月來的幫忙, 4 | 附件是本月份的薪資明細表, 5 | 預設密碼為您的身份證字號。 6 | 若有任何問題歡迎隨時向公司提出 :) 7 | 8 | ------------------------------------------------------------ 9 | 本信件為自動產生,請勿直接回覆 10 | -------------------------------------------------------------------------------- /app/models/term.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class Term < ApplicationRecord 3 | belongs_to :employee 4 | has_many :salaries, dependent: :restrict_with_error 5 | 6 | scope :ordered, -> { order(start_date: :desc) } 7 | end 8 | -------------------------------------------------------------------------------- /app/views/reports/_cell.haml: -------------------------------------------------------------------------------- 1 | - if cell.present? 2 | - if cell.adjusted_amount.zero? 3 | %td 0 4 | - else 5 | %td= link_to number_with_delimiter(cell.adjusted_amount), edit_payroll_path(cell.payroll_id) 6 | - else 7 | %td 0 8 | -------------------------------------------------------------------------------- /db/migrate/20181010071005_add_irregular_income_to_statements.rb: -------------------------------------------------------------------------------- 1 | class AddIrregularIncomeToStatements < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :statements, :irregular_income, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181026041233_add_income_type_to_extra_entries.rb: -------------------------------------------------------------------------------- 1 | class AddIncomeTypeToExtraEntries < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :extra_entries, :income_type, :string, default: "salary" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181028141152_update_payroll_details_to_version_2.rb: -------------------------------------------------------------------------------- 1 | class UpdatePayrollDetailsToVersion2 < ActiveRecord::Migration[5.2] 2 | def change 3 | update_view :payroll_details, version: 2, revert_to_version: 1 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200703065915_add_detail_to_statement.rb: -------------------------------------------------------------------------------- 1 | class AddDetailToStatement < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :statements, :gain, :jsonb 4 | add_column :statements, :loss, :jsonb 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 5 | driven_by :selenium, using: :chrome, screen_size: [1400, 1400] 6 | end 7 | -------------------------------------------------------------------------------- /app/decorators/payroll_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module PayrollDecorator 3 | def payment_period 4 | "#{year}-#{sprintf('%02d', month)}" 5 | end 6 | 7 | def role 8 | salary.given_role 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20180412032053_add_bank_transfer_type_to_employees.rb: -------------------------------------------------------------------------------- 1 | class AddBankTransferTypeToEmployees < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :employees, :bank_transfer_type, :string, default: "salary" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180604064006_add_monthly_wage_adjustment_to_salary.rb: -------------------------------------------------------------------------------- 1 | class AddMonthlyWageAdjustmentToSalary < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :salaries, :monthly_wage_adjustment, :float, default: 1.0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/application.yml.sample: -------------------------------------------------------------------------------- 1 | company_bank_account: "1234567890123" 2 | email_sender: "test@example.com" 3 | statement_bcc: "bob@example.com, alice@example.com" 4 | company_tax_id: "" 5 | company_labor_insurance_id: "" 6 | company_health_insurance_id: "" 7 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /db/migrate/20181108055027_remove_dates_from_employee.rb: -------------------------------------------------------------------------------- 1 | class RemoveDatesFromEmployee < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_column :employees, :start_date, :date 4 | remove_column :employees, :end_date, :date 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/terms/_form.haml: -------------------------------------------------------------------------------- 1 | %table.table 2 | %tr 3 | %th.col-xs-1= f.label "起始日期" 4 | %td.col-xs-2= f.input_field :start_date, type: :date 5 | %th.col-xs-1= f.label "結束日期" 6 | %td.col-xs-2= f.input_field :end_date, type: :date, include_blank: true 7 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /app/views/terms/new.haml: -------------------------------------------------------------------------------- 1 | %h1 建立合作期間資料 2 | 3 | = simple_form_for @term, url: employee_terms_path, method: :post do |f| 4 | = render "/employees/info", employee: @employee 5 | = render "form", f: f, employee: @employee 6 | 7 | .text-center 8 | = f.submit "送出" 9 | -------------------------------------------------------------------------------- /app/views/statements/_details.haml: -------------------------------------------------------------------------------- 1 | - details.each do |i| 2 | %tr 3 | %th.text-center= i[0].keys.first 4 | %td.text-right= number_with_delimiter i[0].values.first 5 | %th.text-center= i[1].keys.first 6 | %td.text-right= number_with_delimiter i[1].values.first 7 | -------------------------------------------------------------------------------- /app/views/terms/edit.haml: -------------------------------------------------------------------------------- 1 | %h1 修改合作期間資料 2 | 3 | = simple_form_for @term, url: employee_term_path(@employee), method: :put do |f| 4 | = render "/employees/info", employee: @employee 5 | = render "form", f: f, employee: @employee 6 | 7 | .text-center 8 | = f.submit "送出" 9 | -------------------------------------------------------------------------------- /app/views/payrolls/_festival_bonus_form.haml: -------------------------------------------------------------------------------- 1 | %h2 三節禮金 2 | %table.table 3 | %tr 4 | %th.col-sm-1= f.label "名目" 5 | %td= f.input_field :festival_type, collection: Payroll::FESTIVAL_BONUS 6 | %tr 7 | %th.col-sm-1= f.label "金額" 8 | %td= f.input_field :festival_bonus 9 | -------------------------------------------------------------------------------- /db/migrate/20181013135559_add_festival_to_payrolls.rb: -------------------------------------------------------------------------------- 1 | class AddFestivalToPayrolls < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :payrolls, :festival_bonus, :integer, default: 0 4 | add_column :payrolls, :festival_type, :string, default: nil 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | /* 2 | *= require_self 3 | */ 4 | @import "bootstrap-sprockets"; 5 | @import "bootstrap"; 6 | 7 | body { 8 | padding: 5px; 9 | } 10 | 11 | .navbar { 12 | margin-bottom: 15px; 13 | } 14 | 15 | .highlight { 16 | color: #f00; 17 | } 18 | -------------------------------------------------------------------------------- /db/migrate/20180413054837_add_insurance_to_salaries.rb: -------------------------------------------------------------------------------- 1 | class AddInsuranceToSalaries < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :salaries, :insured_for_health, :integer, default: 0 4 | add_column :salaries, :insured_for_labor, :integer, default: 0 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/kaminari/_page.html.haml: -------------------------------------------------------------------------------- 1 | - if page.current? 2 | %li.active 3 | = content_tag :a, page, remote: remote, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil)) 4 | - else 5 | %li 6 | = link_to page, url, remote: remote, rel: (page.next? ? 'next' : (page.prev? ? 'prev' : nil)) 7 | -------------------------------------------------------------------------------- /app/views/payrolls/_initiate_link.haml: -------------------------------------------------------------------------------- 1 | %h3= init_button_text 2 | .initiate_link 3 | = link_to "月薪制", init_regulars_path(year: init_year, month: init_month), class: "btn btn-ms btn-primary" 4 | = link_to "兼職外包", parttimers_path(year: init_year, month: init_month), class: "btn btn-ms btn-primary" 5 | -------------------------------------------------------------------------------- /app/views/statements/index.haml: -------------------------------------------------------------------------------- 1 | %h1 發薪紀錄 2 | 3 | = search_form_for @query do |f| 4 | = f.search_field :year_eq 5 | = f.label "年" 6 | = f.search_field :month_eq 7 | = f.label "月" 8 | = f.submit "查詢" 9 | 10 | - if params[:query] 11 | = render "list", statements: @statements 12 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /app/views/payrolls/_extra_entry_fields.haml: -------------------------------------------------------------------------------- 1 | %tr.nested-fields 2 | %td= f.input_field :title 3 | %td= f.input_field :amount 4 | %td= f.input_field :income_type, collection: ExtraEntry::INCOME_TYPE 5 | %td= f.input_field :note 6 | %td= link_to_remove_association "移除", f, class: "btn btn-xs btn-danger" 7 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../config/application', __dir__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/pdf.pdf.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html 3 | %head 4 | %title Fortuna 5 | = stylesheet_link_tag "http://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", media: "all" 6 | = wicked_pdf_stylesheet_link_tag controller_name 7 | %body{id: action_name, class: controller_name} 8 | = yield 9 | -------------------------------------------------------------------------------- /app/views/payrolls/index.haml: -------------------------------------------------------------------------------- 1 | %h1 薪資單產生器 2 | 3 | = search_form_for @query do |f| 4 | = f.search_field :year_eq 5 | = f.label "年" 6 | = f.search_field :month_eq 7 | = f.label "月" 8 | = f.submit "查詢" 9 | 10 | = render "initiate_link" 11 | 12 | - if params[:query] 13 | = render "list", payrolls: @result 14 | -------------------------------------------------------------------------------- /app/views/reports/_header.haml: -------------------------------------------------------------------------------- 1 | %h1 年度報表 2 | = form_tag(reports_path, method: :get) do |f| 3 | = select_tag :year, options_for_select(Report.years, (params[:year] || Date.today.year)) 4 | = label_tag "年度" 5 | = select_tag :report_type, options_for_select(Report::INCOME_TYPE, params[:report_type]) 6 | = submit_tag "送出" 7 | -------------------------------------------------------------------------------- /app/views/payrolls/_overtime_fields.haml: -------------------------------------------------------------------------------- 1 | %tr.nested-fields 2 | %td= f.input_field :date, default: Date.new(@payroll.year, @payroll.month, 1) 3 | %td= f.input_field :hours 4 | %td= f.input_field :rate, collection: Overtime::RATE, default: "weekday" 5 | %td= link_to_remove_association "移除", f, class: "btn btn-xs btn-danger" 6 | -------------------------------------------------------------------------------- /db/migrate/20181011084858_create_corrections.rb: -------------------------------------------------------------------------------- 1 | class CreateCorrections < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :corrections do |t| 4 | t.integer :statement_id 5 | t.integer :amount, default: 0 6 | t.string :description 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/salaries/new.haml: -------------------------------------------------------------------------------- 1 | %h1 建立薪資資料 2 | 3 | = simple_form_for @salary, url: employee_salaries_path, method: :post do |f| 4 | = render "/employees/info", employee: @employee 5 | = render "/terms/info", term: @term 6 | = render "form", f: f, employee: @employee, term: @term 7 | 8 | .text-center 9 | = f.submit "送出" 10 | -------------------------------------------------------------------------------- /app/views/statements/edit.haml: -------------------------------------------------------------------------------- 1 | %h1 報表校正 2 | 3 | = render template: @details[:template], details: @details 4 | 5 | = link_to "薪資明細設定", edit_payroll_path(@statement.payroll), class: "btn btn-sm btn-default" 6 | 7 | = simple_form_for @statement do |f| 8 | = render "correction_form", f: f 9 | .text-center 10 | = f.submit "送出" 11 | -------------------------------------------------------------------------------- /db/migrate/20180106155844_create_overtimes.rb: -------------------------------------------------------------------------------- 1 | class CreateOvertimes < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :overtimes do |t| 4 | t.date :date 5 | t.string :rate 6 | t.float :hours, default: 0 7 | 8 | t.integer :payroll_id 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/decorators/salary_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SalaryDecorator 3 | def wage_type 4 | monthly_wage.positive? ? "月薪" : "時薪" 5 | end 6 | 7 | def wage 8 | monthly_wage.positive? ? monthly_wage : hourly_wage 9 | end 10 | 11 | def split_status 12 | split? ? "拆單" : "不拆單" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/services/salary_service/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SalaryService 3 | class Destroy < Base 4 | def call 5 | salary.destroy 6 | sync_payroll_relations 7 | sync_statements 8 | end 9 | 10 | private 11 | 12 | def effective_date 13 | nil 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/terms/show.haml: -------------------------------------------------------------------------------- 1 | %h1 員工資料 2 | = render "/employees/info", employee: @term.employee 3 | = render "info", term: @term 4 | 5 | = link_to "修改合作期間資料", edit_employee_term_path(@employee), class: "btn btn-sm btn-default" 6 | = link_to "刪除合作期間資料", employee_term_path(@employee), method: :delete, data: { confirm: "確認刪除?" }, class: "btn btn-sm btn-danger" 7 | -------------------------------------------------------------------------------- /app/views/salaries/edit.haml: -------------------------------------------------------------------------------- 1 | %h1 修改薪資資料 2 | 3 | = simple_form_for @salary, url: employee_salary_path(@employee), method: :put do |f| 4 | = render "/employees/info", employee: @employee 5 | = render "terms/info", term: @salary.term 6 | = render "form", f: f, employee: @employee, term: @salary.term 7 | 8 | .text-center 9 | = f.submit "送出" 10 | -------------------------------------------------------------------------------- /lib/templates/haml/scaffold/_form.html.haml: -------------------------------------------------------------------------------- 1 | = simple_form_for(@<%= singular_table_name %>) do |f| 2 | = f.error_notification 3 | 4 | .form-inputs 5 | <%- attributes.each do |attribute| -%> 6 | = f.<%= attribute.reference? ? :association : :input %> :<%= attribute.name %> 7 | <%- end -%> 8 | 9 | .form-actions 10 | = f.button :submit 11 | -------------------------------------------------------------------------------- /app/views/salaries/show.haml: -------------------------------------------------------------------------------- 1 | %h1 員工資料 2 | = render "/employees/info", employee: @salary.employee 3 | = render "info", salary: @salary 4 | 5 | = link_to "修改薪資資料", edit_employee_salary_path(@employee), class: "btn btn-sm btn-default" 6 | = link_to "刪除薪資資料", employee_salary_path(@employee), method: :delete, data: { confirm: "確認刪除?" }, class: "btn btn-sm btn-danger" 7 | -------------------------------------------------------------------------------- /db/migrate/20181106120752_create_terms.rb: -------------------------------------------------------------------------------- 1 | class CreateTerms < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :terms do |t| 4 | t.date :start_date, default: nil, index: true 5 | t.date :end_date, default: nil, index: true 6 | 7 | t.integer :employee_id, index: true 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/factories/payroll.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :payroll do 5 | transient do 6 | build_statement_immediatedly { false } 7 | end 8 | 9 | after :create do |payroll, ev| 10 | StatementService::Builder.call(payroll) if ev.build_statement_immediatedly 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | VENDOR_PATH = File.expand_path('..', __dir__) 3 | Dir.chdir(VENDOR_PATH) do 4 | begin 5 | exec "yarnpkg #{ARGV.join(" ")}" 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/payrolls/_basic_form.haml: -------------------------------------------------------------------------------- 1 | %h2 基本項目 2 | %table.table 3 | %tr 4 | %th.col-sm-1= f.label "工作時數" 5 | %td= f.input_field :parttime_hours 6 | %th= f.label "扣薪事假時數" 7 | %td= f.input_field :leavetime_hours 8 | %tr 9 | %th= f.label "扣薪病假時數" 10 | %td= f.input_field :sicktime_hours 11 | %th= f.label "特休折換時數" 12 | %td= f.input_field :vacation_refund_hours 13 | -------------------------------------------------------------------------------- /app/views/payrolls/_list.haml: -------------------------------------------------------------------------------- 1 | %table.table 2 | %tr 3 | %th # 4 | %th 員工 5 | %th 身份 6 | %th 計算月份 7 | %th 操作 8 | - payrolls.each do |payroll| 9 | %tr 10 | %td= payroll.id 11 | %td= payroll.employee_name 12 | %td= payroll.role 13 | %td= payroll.payment_period 14 | %td= render "statements", payroll: payroll 15 | 16 | = paginate payrolls 17 | -------------------------------------------------------------------------------- /config/holidays/2015.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | - 2 # 元旦彈性 5 | 2: 6 | - 18 # 除夕 7 | - 19 # 春節 8 | - 20 # 春節 9 | - 23 # 春節 10 | - 27 # 228 補假 11 | 4: 12 | - 3 # 兒童節補假 13 | - 6 # 清明補假 14 | 5: 15 | - 1 # 勞動節 16 | 6: 17 | - 19 # 端午補假 18 | 9: 19 | - 28 # 中秋補假 20 | 10: 21 | - 9 # 國慶補假 22 | makeup_days: 23 | -------------------------------------------------------------------------------- /config/holidays/2025.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | - 27 # 春節 5 | - 28 # 春節 6 | - 29 # 春節 7 | - 30 # 春節 8 | - 31 # 春節 9 | 10 | 2: 11 | - 28 # 228 12 | 4: 13 | - 3 # 清明 14 | - 4 # 清明 15 | 5: 16 | - 1 # 勞動節 17 | - 30 # 端午節 18 | 10: 19 | - 6 # 中秋節 20 | - 10 # 國慶日 21 | makeup_days: 22 | 2: 23 | - 8 # 春節補班 24 | -------------------------------------------------------------------------------- /db/migrate/20180114122028_create_statements.rb: -------------------------------------------------------------------------------- 1 | class CreateStatements < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :statements do |t| 4 | t.integer :amount, default: 0 5 | t.integer :year 6 | t.integer :month 7 | t.integer :splits, default: nil, array: true 8 | 9 | t.integer :payroll_id 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class ApplicationController < ActionController::Base 3 | protect_from_forgery with: :exception 4 | 5 | def store_location 6 | session[:return_to] = request.fullpath if request.get? 7 | end 8 | 9 | private 10 | 11 | def not_found 12 | raise ActionController::RoutingError, "Not Found" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/holidays/2022.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 31 # 除夕 4 | 5 | 2: 6 | - 1 # 春節 7 | - 2 # 春節 8 | - 3 # 春節 9 | - 4 # 春節 10 | - 28 # 228 11 | 4: 12 | - 4 # 兒童節 13 | - 5 # 清明 14 | 5: 15 | - 2 # 勞動節補假 16 | 6: 17 | - 3 # 端午節 18 | 9: 19 | - 9 # 中秋節補假 20 | 10: 21 | - 10 # 國慶日 22 | makeup_days: 23 | 1: 24 | - 22 # 春節補班 25 | -------------------------------------------------------------------------------- /config/initializers/kaminari_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | Kaminari.configure do |config| 3 | config.default_per_page = 50 4 | # config.max_per_page = nil 5 | # config.window = 4 6 | # config.outer_window = 0 7 | # config.left = 0 8 | # config.right = 0 9 | # config.page_method_name = :page 10 | # config.param_name = :page 11 | # config.params_on_first_page = false 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20180107080943_create_extra_entries.rb: -------------------------------------------------------------------------------- 1 | class CreateExtraEntries < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :extra_entries do |t| 4 | t.string :title 5 | t.integer :amount, default: 0 6 | t.boolean :taxable, default: false 7 | t.string :note, null: true 8 | 9 | t.integer :payroll_id 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/payrolls/_statements.haml: -------------------------------------------------------------------------------- 1 | = link_to "員工資料", employee_path(payroll.employee), class: "btn btn-xs btn-default" 2 | - if payroll.statement 3 | = link_to "修改明細", edit_payroll_path(payroll), class: "btn btn-xs btn-default" 4 | = link_to "報表校正", edit_statement_path(payroll.statement), class: "btn btn-xs btn-warning" 5 | - else 6 | = link_to "設定明細", edit_payroll_path(payroll), class: "btn btn-xs btn-warning" 7 | -------------------------------------------------------------------------------- /config/holidays/2024.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | 5 | 2: 6 | - 8 # 春節 7 | - 9 # 春節 8 | - 12 # 春節 9 | - 13 # 春節 10 | - 14 # 春節 11 | - 28 # 228 12 | 4: 13 | - 4 # 清明 14 | - 5 # 清明 15 | 5: 16 | - 1 # 勞動節 17 | 6: 18 | - 10 # 端午節 19 | 9: 20 | - 17 # 中秋節 21 | 10: 22 | - 10 # 國慶日 23 | makeup_days: 24 | 2: 25 | - 17 # 春節補班 26 | -------------------------------------------------------------------------------- /app/services/salary_service/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SalaryService 3 | class Update < Base 4 | def call 5 | salary.update(params) 6 | sync_payroll_relations if effective_date_change? 7 | sync_statements 8 | end 9 | 10 | private 11 | 12 | def effective_date_change? 13 | salary.effective_date_before_last_save != effective_date 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /app/services/income_tax_service/regular_employee.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class RegularEmployee 4 | include Callable 5 | 6 | attr_reader :payroll 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | end 11 | 12 | def call 13 | IncomeTaxService::InsurancedSalary.call(payroll) + IncomeTaxService::IrregularIncome.call(payroll) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/extra_entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class ExtraEntry < ApplicationRecord 3 | belongs_to :payroll 4 | 5 | INCOME_TYPE = { "薪資": "salary", "補助": "subsidy", "獎金": "bonus" }.freeze 6 | 7 | scope :yearly_subsidy_report, ->(year) { 8 | includes(:payroll, payroll: :employee) 9 | .where("amount > 0 AND income_type = ?", :subsidy) 10 | .joins(:payroll) 11 | .where(payrolls: { year: year }) 12 | } 13 | end 14 | -------------------------------------------------------------------------------- /test/factories/employee.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "taiwanese_id_validator/twid_generator" 4 | 5 | FactoryBot.define do 6 | factory :employee do 7 | name do 8 | Faker::Name.name 9 | end 10 | company_email do 11 | Faker::Internet.email 12 | end 13 | personal_email do 14 | Faker::Internet.email 15 | end 16 | id_number do 17 | TwidGenerator.generate 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/assets/stylesheets/payrolls.scss: -------------------------------------------------------------------------------- 1 | .payrolls#show { 2 | .table { 3 | margin-bottom: 0; 4 | } 5 | 6 | .row { 7 | margin-right: 0; 8 | margin-left: 0; 9 | 10 | div.col-xs-3, div.col-xs-6 { 11 | padding-right: 0; 12 | padding-left: 0; 13 | } 14 | } 15 | } 16 | .payrolls#index { 17 | #payroll_search { 18 | margin-bottom: 20px; 19 | } 20 | 21 | .initiate_link { 22 | margin: 10px 0 20px 0; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/services/payroll_init_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class PayrollInitServiceTest < ActiveSupport::TestCase 5 | def test_initiate_payroll 6 | employee = build(:employee) 7 | salary = build(:salary, employee: employee) 8 | 9 | StatementService::Builder.any_instance.expects(:call).returns(true) 10 | 11 | assert PayrollInitService.call(2018, 1, employee.id, salary.id) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/mailers/statement_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class StatementMailer < ApplicationMailer 4 | def notify_email(statement) 5 | builder = StatementPDFBuilder.new(statement) 6 | attachments[builder.filename] = builder.encrypted_pdf 7 | mail( 8 | to: statement.employee.email, 9 | bcc: ENV["statement_bcc"].split(","), 10 | subject: builder.email_subject 11 | ) 12 | builder.delete_files 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/services/format_service/extra_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FormatService 3 | class ExtraIncome 4 | include Callable 5 | 6 | attr_reader :payroll, :hash 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | @hash = {} 11 | end 12 | 13 | def call 14 | payroll.extra_entries.map { |entry| hash[entry.title] = entry.amount if entry.amount.positive? } 15 | hash 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/views/reports_v01.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | statements.id AS statement_id, 5 | employees.name, 6 | payrolls.year, 7 | payrolls.month, 8 | salaries.tax_code, 9 | statements.amount 10 | FROM employees 11 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 12 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 13 | INNER JOIN statements ON payrolls.id = statements.payroll_id 14 | -------------------------------------------------------------------------------- /app/services/format_service/extra_deductions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FormatService 3 | class ExtraDeductions 4 | include Callable 5 | 6 | attr_reader :payroll, :hash 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | @hash = {} 11 | end 12 | 13 | def call 14 | payroll.extra_entries.map { |entry| hash[entry.title] = entry.amount.abs if entry.amount.negative? } 15 | hash 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/views/kaminari/_paginator.html.haml: -------------------------------------------------------------------------------- 1 | = paginator.render do 2 | %ul.pagination 3 | = first_page_tag unless current_page.first? 4 | = prev_page_tag unless current_page.first? 5 | - each_page do |page| 6 | - if page.left_outer? || page.right_outer? || page.inside_window? 7 | = page_tag page 8 | - elsif !page.was_truncated? 9 | = gap_tag 10 | = next_page_tag unless current_page.last? 11 | = last_page_tag unless current_page.last? 12 | -------------------------------------------------------------------------------- /config/holidays/2018.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | 2: 5 | - 15 # 除夕 6 | - 16 # 春節 7 | - 19 # 春節 8 | - 20 # 春節 9 | - 28 # 228 10 | 4: 11 | - 4 # 兒童節 12 | - 5 # 清明 13 | - 6 # 清明彈性 14 | 5: 15 | - 1 # 勞動節 16 | 6: 17 | - 18 # 端午節 18 | 9: 19 | - 24 # 中秋節 20 | 10: 21 | - 10 # 國慶日 22 | 12: 23 | - 31 # 元旦彈性 24 | makeup_days: 25 | 3: 26 | - 31 # 清明補班 27 | 12: 28 | - 22 # 元旦補班 29 | -------------------------------------------------------------------------------- /app/services/income_tax_service/insuranced_salary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class InsurancedSalary 4 | include Callable 5 | 6 | attr_reader :payroll 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | end 11 | 12 | def call 13 | insuranced_salary_tax 14 | end 15 | 16 | private 17 | 18 | def insuranced_salary_tax 19 | payroll.salary.fixed_income_tax 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20180105170858_create_payrolls.rb: -------------------------------------------------------------------------------- 1 | class CreatePayrolls < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :payrolls do |t| 4 | t.integer :year 5 | t.integer :month 6 | t.float :parttime_hours, default: 0 7 | t.float :leavetime_hours, default: 0 8 | t.float :sicktime_hours, default: 0 9 | t.float :vacation_refund_hours, default: 0 10 | 11 | t.integer :employee_id 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/views/salary_trackers_v01.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | employees.id AS employee_id, 3 | terms.id AS term_id, 4 | salaries.id AS salary_id, 5 | employees.name AS name, 6 | terms.start_date AS term_start, 7 | terms.end_date AS term_end, 8 | salaries.role AS role, 9 | salaries.effective_date AS salary_start 10 | FROM employees 11 | INNER JOIN terms ON employees.id = terms.employee_id 12 | INNER JOIN salaries ON employees.id = salaries.employee_id 13 | WHERE salaries.term_id = terms.id 14 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "simplecov" 3 | SimpleCov.start "rails" do 4 | add_filter "app/controllers" 5 | end 6 | 7 | require File.expand_path("../config/environment", __dir__) 8 | require "rails/test_help" 9 | require "minitest/unit" 10 | require "mocha/minitest" 11 | 12 | class ActiveSupport::TestCase 13 | include FactoryBot::Syntax::Methods 14 | end 15 | 16 | DatabaseRewinder.strategy = :transaction 17 | DatabaseRewinder.clean_with(:truncation) 18 | -------------------------------------------------------------------------------- /app/views/statements/_correction_form.haml: -------------------------------------------------------------------------------- 1 | %h2 校正項目 2 | %table.table 3 | %tr 4 | %th.col-sm-2 金額 5 | %th.col-sm-4 說明 6 | %th.col-sm-4 操作 7 | %tbody#correction_items 8 | = f.simple_fields_for :corrections do |correction| 9 | = render "correction_fields", f: correction 10 | 11 | = link_to_add_association "新增校正項目", f, :corrections, class: "btn btn-sm btn-primary", data: { "association-insertion-node": "tbody#correction_items", "association-insertion-method": "append" } 12 | -------------------------------------------------------------------------------- /db/migrate/20180103063747_create_employees.rb: -------------------------------------------------------------------------------- 1 | class CreateEmployees < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :employees do |t| 4 | t.string :name 5 | t.string :company_email 6 | t.string :personal_email 7 | t.string :id_number 8 | t.string :residence_address 9 | t.date :birthday 10 | t.string :bank_account 11 | t.date :start_date 12 | t.date :end_date 13 | 14 | t.timestamps 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/decorators/employee_decorator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class EmployeeDecoratorTest < ActiveSupport::TestCase 5 | def test_role 6 | assert_equal "正職", decorated_subject.role 7 | end 8 | 9 | private 10 | 11 | def decorated_subject 12 | ActiveDecorator::Decorator.instance.decorate( 13 | build(:employee) { |employee| create(:salary, role: "regular", employee: employee, term: build(:term)) } 14 | ) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/services/salary_service/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SalaryService 3 | class Create < Base 4 | def call 5 | salary.save 6 | sync_payroll_relations if effective_date_backtrack? 7 | sync_statements 8 | end 9 | 10 | private 11 | 12 | def effective_date_backtrack? 13 | recent_salary.effective_date > effective_date 14 | end 15 | 16 | def recent_salary 17 | Salary.recent_for(salary.employee) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/views/reports_v02.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | statements.id AS statement_id, 5 | employees.name, 6 | payrolls.year, 7 | payrolls.month, 8 | salaries.tax_code, 9 | statements.amount, 10 | statements.irregular_income 11 | FROM employees 12 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 13 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 14 | INNER JOIN statements ON payrolls.id = statements.payroll_id 15 | -------------------------------------------------------------------------------- /app/views/payrolls/_overtime_form.haml: -------------------------------------------------------------------------------- 1 | %h2 加班紀錄 2 | %table.table 3 | %tr 4 | %th.col-sm-2 加班日期 5 | %th.col-sm-2 加班時數 6 | %th.col-sm-2 適用費率 7 | %th.col-sm-4 操作 8 | %tbody#overtime_items 9 | = f.simple_fields_for :overtimes do |overtime| 10 | = render "overtime_fields", f: overtime 11 | 12 | = link_to_add_association "新增加班紀錄", f, :overtimes, class: "btn btn-sm btn-primary", data: { "association-insertion-node": "tbody#overtime_items", "association-insertion-method": "append" } 13 | -------------------------------------------------------------------------------- /app/views/payrolls/parttimers.haml: -------------------------------------------------------------------------------- 1 | %h1 薪資單產生器 2 | 3 | = form_for :parttimers do |f| 4 | %table.table 5 | %tr 6 | %th.col-sm-1 # 7 | %th.col-sm-1 姓名 8 | %th.col-sm-1 選取 9 | %th{width: "100%"} 身份 10 | - @result.each do |row| 11 | %tr 12 | %td= row.employee_id 13 | %td= row.name 14 | %td 15 | %input{type: "checkbox", name: "parttimers[]", value: "#{row.employee_id}, #{row.salary_id}"} 16 | %td= row.given_role 17 | %p= f.submit 18 | -------------------------------------------------------------------------------- /config/holidays/2017.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 2 # 元旦補假 4 | - 27 # 除夕 5 | - 30 # 春節 6 | - 31 # 春節補假 7 | 2: 8 | - 1 # 春節補假 9 | - 27 # 228 彈性 10 | - 28 # 228 11 | 4: 12 | - 3 # 兒童節 13 | - 4 # 清明 14 | 5: 15 | - 1 # 勞動節 16 | - 28 # 端午彈性 17 | - 29 # 端午節 18 | 10: 19 | - 4 # 中秋節 20 | - 9 # 國慶彈性 21 | - 10 # 國慶日 22 | makeup_days: 23 | 2: 24 | - 18 # 228 補班 25 | 6: 26 | - 3 # 端午補班 27 | 9: 28 | - 30 # 國慶補班 29 | -------------------------------------------------------------------------------- /config/holidays/2020.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | - 23 # 除夕彈性 5 | - 24 # 除夕 6 | - 27 # 春節 7 | - 28 # 春節 8 | - 29 # 春節 9 | 2: 10 | - 28 # 228 11 | 4: 12 | - 2 # 兒童節 13 | - 3 # 清明 14 | 5: 15 | - 1 # 勞動節 16 | 6: 17 | - 25 # 端午節 18 | - 26 # 端午彈性 19 | 10: 20 | - 1 # 中秋節 21 | - 2 # 中秋彈性 22 | - 9 # 國慶日 23 | makeup_days: 24 | 2: 25 | - 15 # 春節補班 26 | 6: 27 | - 20 # 端午補班 28 | 9: 29 | - 26 # 中秋補班 30 | -------------------------------------------------------------------------------- /app/lib/collection_translatable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 使用方式參考 Salary::ROLE 3 | module CollectionTranslatable 4 | def method_missing(method_name) 5 | if (match = method_name.id2name.match(%r{^given_(\w+)})) 6 | "#{model_name}::#{match[1].upcase}".constantize.key(self.send(match[1])).to_s 7 | else 8 | super 9 | end 10 | end 11 | 12 | def respond_to_missing?(method_name, include_private = false) 13 | method_name.to_s.start_with?("given_") || super 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/holidays/2021.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | 5 | 2: 6 | - 10 # 除夕彈性 7 | - 11 # 除夕 8 | - 12 # 春節 9 | - 15 # 春節 10 | - 16 # 春節 11 | 3: 12 | - 1 # 228 補假 13 | 4: 14 | - 2 # 兒童節補假 15 | - 5 # 清明補假 16 | 5: 17 | - 1 # 勞動節 18 | 6: 19 | - 14 # 端午節 20 | 9: 21 | - 20 # 中秋節彈性 22 | - 21 # 中秋節 23 | 10: 24 | - 11 # 國慶日補假 25 | 12: 26 | - 31 # 元旦補假 27 | makeup_days: 28 | 2: 29 | - 20 # 春節補班 30 | 9: 31 | - 11 # 中秋補班 32 | -------------------------------------------------------------------------------- /config/holidays/2016.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | 2: 5 | - 8 # 春節 6 | - 9 # 春節 7 | - 10 # 春節 8 | - 11 # 春節 9 | - 12 # 春節彈性 10 | - 29 # 228 補假 11 | 4: 12 | - 4 # 兒童節 13 | - 5 # 清明補假 14 | 5: 15 | - 2 # 勞動節補假 16 | 6: 17 | - 9 # 端午節 18 | - 10 # 端午彈性 19 | 9: 20 | - 15 # 中秋節 21 | - 16 # 中秋彈性 22 | 10: 23 | - 10 # 國慶日 24 | makeup_days: 25 | 1: 26 | - 30 # 春節補班 27 | 6: 28 | - 4 # 端午補班 29 | 9: 30 | - 10 # 中秋補班 31 | -------------------------------------------------------------------------------- /app/views/payrolls/edit.haml: -------------------------------------------------------------------------------- 1 | %h1 #{@payroll.payment_period} 薪資單明細設定 2 | 3 | = render "/employees/info", employee: @payroll.employee 4 | = render "/salaries/info", salary: @payroll.salary 5 | 6 | = link_to "報表校正", edit_statement_path(@payroll.statement), class: "btn btn-sm btn-warning" 7 | 8 | = simple_form_for @payroll do |f| 9 | = render "basic_form", f: f 10 | = render "festival_bonus_form", f: f 11 | = render "overtime_form", f: f 12 | = render "extra_entry_form", f: f 13 | 14 | .text-center 15 | = f.submit "送出" 16 | -------------------------------------------------------------------------------- /config/holidays/2019.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 1 # 元旦 4 | 2: 5 | - 4 # 除夕 6 | - 5 # 春節 7 | - 6 # 春節 8 | - 7 # 春節 9 | - 8 # 春節 10 | - 28 # 228 11 | 3: 12 | - 1 # 228彈性 13 | 4: 14 | - 4 # 兒童節 15 | - 5 # 清明 16 | 5: 17 | - 1 # 勞動節 18 | 6: 19 | - 7 # 端午節 20 | 9: 21 | - 13 # 中秋節 22 | 10: 23 | - 10 # 國慶日 24 | - 11 # 國慶日彈性 25 | makeup_days: 26 | 1: 27 | - 19 # 春節補班 28 | 2: 29 | - 23 # 228補班 30 | 10: 31 | - 5 # 國慶日補班 32 | -------------------------------------------------------------------------------- /app/services/calculation_service/sicktime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module CalculationService 3 | class Sicktime 4 | include Callable 5 | 6 | attr_reader :hours, :salary 7 | 8 | def initialize(payroll) 9 | @hours = payroll.sicktime_hours 10 | @salary = payroll.salary 11 | end 12 | 13 | def call 14 | (hours * hourly_rate * 0.5).to_i 15 | end 16 | 17 | private 18 | 19 | def hourly_rate 20 | (salary.income_with_subsidies / 30 / 8.0).ceil 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude_paths: 3 | - config 4 | - lib 5 | - test 6 | - db 7 | 8 | detectors: 9 | IrresponsibleModule: 10 | enabled: false 11 | NestedIterators: 12 | max_allowed_nesting: 2 13 | DuplicateMethodCall: 14 | max_calls: 2 15 | UtilityFunction: 16 | enabled: false 17 | BooleanParameter: 18 | exclude: 19 | - "CollectionTranslatable#respond_to_missing?" # monkey patch, can't fix 20 | 21 | directories: 22 | "app/controllers": 23 | InstanceVariableAssumption: 24 | enabled: false 25 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/supplement_premium_rate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module HealthInsuranceService 3 | class SupplementPremiumRate 4 | include Callable 5 | 6 | attr_reader :date 7 | 8 | def initialize(year, month) 9 | @date = Date.new(year, month, 1) 10 | end 11 | 12 | def call 13 | if date >= Date.new(2021, 1, 1) 14 | 0.0211 15 | elsif date >= Date.new(2016, 1, 1) 16 | 0.0191 17 | else 18 | 0.02 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/services/calculation_service/vacation_refund.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module CalculationService 3 | class VacationRefund 4 | include Callable 5 | 6 | attr_reader :hours, :salary 7 | 8 | def initialize(payroll) 9 | @hours = payroll.vacation_refund_hours 10 | @salary = payroll.salary 11 | end 12 | 13 | def call 14 | (hourly_rate * hours).to_i 15 | end 16 | 17 | private 18 | 19 | def hourly_rate 20 | (salary.income_with_subsidies / 30 / 8.0).ceil 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/payrolls/_extra_entry_form.haml: -------------------------------------------------------------------------------- 1 | %h2 自訂項目 2 | %table.table 3 | %tr 4 | %th.col-sm-1 項目名稱 5 | %th.col-sm-1 金額 6 | %th.col-sm-1 稅別 7 | %th.col-sm-2 備註 8 | %th.col-sm-4 操作 9 | %tbody#extra_entry_items 10 | = f.simple_fields_for :extra_entries do |extra_entry| 11 | = render "extra_entry_fields", f: extra_entry 12 | 13 | = link_to_add_association "新增自訂項目", f, :extra_entries, class: "btn btn-sm btn-primary", data: { "association-insertion-node": "tbody#extra_entry_items", "association-insertion-method": "append" } 14 | -------------------------------------------------------------------------------- /db/migrate/20181014044335_add_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddIndexes < ActiveRecord::Migration[5.2] 2 | def change 3 | add_index :employees, :start_date 4 | add_index :employees, :end_date 5 | add_index :payrolls, :salary_id 6 | add_index :payrolls, :employee_id 7 | add_index :salaries, :employee_id 8 | add_index :salaries, :effective_date 9 | add_index :statements, :payroll_id 10 | add_index :overtimes, :payroll_id 11 | add_index :extra_entries, :payroll_id 12 | add_index :corrections, :statement_id 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/models/overtime_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class OvertimeTest < ActiveSupport::TestCase 5 | setup do 6 | create(:overtime, date: "2017-02-01", payroll: build(:payroll)) 7 | create(:overtime, date: "2018-03-15", payroll: build(:payroll)) 8 | create(:overtime, date: "2018-04-15", payroll: build(:payroll)) 9 | end 10 | 11 | def test_yearly_report 12 | assert_equal 1, Overtime.yearly_report(2017).count 13 | assert_equal 2, Overtime.yearly_report(2018).count 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/services/salary_service/sync_payroll_relations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SalaryService 3 | class SyncPayrollRelations 4 | include Callable 5 | 6 | attr_reader :payroll 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | end 11 | 12 | def call 13 | payroll.update(salary: matched_salary) if matched_salary != payroll.salary 14 | end 15 | 16 | private 17 | 18 | def matched_salary 19 | Salary.find(SalaryTracker.salary_by_payroll(payroll: payroll)) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/services/format_service/festival_bonus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FormatService 3 | class FestivalBonus 4 | include Callable 5 | 6 | attr_reader :payroll, :hash 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | @hash = {} 11 | end 12 | 13 | def call 14 | hash[title] = payroll.festival_bonus if payroll.festival_bonus.positive? 15 | hash 16 | end 17 | 18 | private 19 | 20 | def title 21 | Payroll::FESTIVAL_BONUS.key(payroll.festival_type) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/models/overtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class Overtime < ApplicationRecord 3 | belongs_to :payroll 4 | delegate :year, :month, to: :payroll, prefix: true 5 | 6 | has_one :employee, through: :payroll 7 | delegate :id, :name, to: :employee, prefix: true 8 | 9 | RATE = { "平日": "weekday", "休息日": "weekend", "休假日": "holiday", "例假日": "offday" }.freeze 10 | 11 | scope :yearly_report, ->(year) { 12 | includes(:payroll, :employee, payroll: :salary) 13 | .where("date BETWEEN ? AND ?", Date.new(year, 1, 1), Date.new(year, 12, -1)) 14 | } 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == "spring" } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/services/payroll_init_regulars_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class PayrollInitRegularsService 3 | include Callable 4 | 5 | attr_reader :year, :month 6 | 7 | def initialize(year, month) 8 | @year = year.to_i 9 | @month = month.to_i 10 | end 11 | 12 | def call 13 | regulars.map { |tracker| PayrollInitService.call(year, month, tracker.employee_id, tracker.salary_id) } 14 | end 15 | 16 | private 17 | 18 | def regulars 19 | SalaryTracker.on_payroll(year: year, month: month).by_role(role: %w[boss regular contractor]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/services/income_tax_service/insuranced_salary_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class InsurancedSalaryTest 6 | def test_has_insuranced_salary_tax 7 | assert_equal 3000, IncomeTaxService::InsurancedSalary.call(subject) 8 | end 9 | 10 | private 11 | 12 | def subject 13 | build( 14 | :payroll, 15 | year: 2018, 16 | month: 5, 17 | salary: build(:salary, fixed_income_tax: 3000), 18 | employee: build(:employee) 19 | ) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/views/reports/irregular.haml: -------------------------------------------------------------------------------- 1 | = render "header" 2 | = render "title" 3 | 4 | %p= link_to "匯出 CSV", irregular_reports_path(params[:year], format: :csv), class: "btn btn-m btn-primary" 5 | 6 | - if @report.any? 7 | %table.table 8 | %tr 9 | %th.col-sm-2 姓名 10 | %th.col-sm-2 計算月份 11 | %th.col-sm-2 項目 12 | %th 金額 13 | - @report.each do |row| 14 | %tr 15 | %td= link_to row[:name], employee_path(row[:employee_id]) 16 | %td= link_to row[:period], edit_payroll_path(row[:payroll_id]) 17 | %td= row[:description] 18 | %td= number_with_delimiter(row[:amount]) 19 | -------------------------------------------------------------------------------- /db/views/payroll_details_v01.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | payrolls.year, 5 | payrolls.month, 6 | salaries.tax_code, 7 | salaries.monthly_wage, 8 | salaries.insured_for_health, 9 | statements.splits, 10 | statements.amount, 11 | statements.subsidy_income, 12 | statements.bonus_income, 13 | employees.owner 14 | FROM employees 15 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 16 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 17 | INNER JOIN statements ON payrolls.id = statements.payroll_id 18 | WHERE employees.b2b = false 19 | -------------------------------------------------------------------------------- /db/views/payroll_details_v02.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | payrolls.year, 5 | payrolls.month, 6 | salaries.tax_code, 7 | salaries.monthly_wage, 8 | salaries.insured_for_health, 9 | statements.splits, 10 | statements.amount, 11 | statements.subsidy_income, 12 | statements.excess_income, 13 | employees.owner 14 | FROM employees 15 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 16 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 17 | INNER JOIN statements ON payrolls.id = statements.payroll_id 18 | WHERE employees.b2b = false 19 | -------------------------------------------------------------------------------- /test/models/term_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class TermTest < ActiveSupport::TestCase 5 | def test_scope_ordered 6 | prepare_scope_ordered 7 | 8 | assert Term.ordered.each_cons(2).all? do |first, second| 9 | first.start_date >= second.start_date 10 | end 11 | end 12 | 13 | private 14 | 15 | def prepare_scope_ordered 16 | create(:term, start_date: "2018-05-05", employee: build(:employee)) 17 | create(:term, start_date: "2018-10-01", employee: build(:employee)) 18 | create(:term, start_date: "2018-01-23", employee: build(:employee)) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/employees/show.haml: -------------------------------------------------------------------------------- 1 | %h1 員工資料 2 | = render "info", employee: @employee 3 | = link_to "修改員工資料", edit_employee_path(@employee), class: "btn btn-default btn-sm" 4 | = link_to "刪除員工資料", employee_path(@employee), method: :delete, data: { confirm: "確定刪除?" }, class: "btn btn-danger btn-sm" 5 | 6 | %h3 合作期間 7 | = render "terms", terms: @employee.terms.ordered 8 | %p= link_to "新增合作期間", new_employee_term_path(@employee), class: "btn btn-sm btn-primary" 9 | 10 | %h2 薪資紀錄 11 | - if @employee.salaries.any? 12 | = render "salaries", salaries: @employee.salaries.ordered 13 | 14 | %h2 薪資單 15 | = render "payrolls", payrolls: @payrolls.ordered 16 | -------------------------------------------------------------------------------- /test/services/health_insurance_service/supplement_premium_rate_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module HealthInsuranceService 5 | class SupplementPremiumRateTest < ActiveSupport::TestCase 6 | def test_after_202101 7 | assert_equal 0.0211, HealthInsuranceService::SupplementPremiumRate.call(2021, 1) 8 | end 9 | 10 | def test_after_201601 11 | assert_equal 0.0191, HealthInsuranceService::SupplementPremiumRate.call(2016, 1) 12 | end 13 | 14 | def test_after_203101 15 | assert_equal 0.02, HealthInsuranceService::SupplementPremiumRate.call(2013, 1) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/models/payroll_detail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class PayrollDetail < ApplicationRecord 3 | scope :owner_income, ->(year:, month:) { find_by(year: year, month: month, owner: true) } 4 | 5 | scope :yearly_bonus_total_until, ->(year:, month:, employee_id:) { 6 | where(employee_id: employee_id, year: year, splits: nil, tax_code: 50, owner: false) 7 | .where("excess_income > 0 AND month <= ?", month) 8 | .sum(:excess_income) 9 | } 10 | 11 | scope :monthly_excess_payment, ->(year:, month:) { 12 | where(year: year, month: month, splits: nil, tax_code: 50, owner: false) 13 | .where("excess_income > 0") 14 | } 15 | end 16 | -------------------------------------------------------------------------------- /app/views/employees/_info.haml: -------------------------------------------------------------------------------- 1 | %h3 員工資料 2 | %table.table 3 | %tr 4 | %th.col-xs-1 姓名 5 | %td.col-xs-2= employee.name 6 | %th.col-xs-1 戶籍地址 7 | %td.col-xs-2= employee.residence_address 8 | %tr 9 | %th 身分證字號 10 | %td= employee.id_number 11 | %th 出生年月日 12 | %td= employee.birthday 13 | %tr 14 | %th 公司信箱 15 | %td= employee.company_email 16 | %th 私人信箱 17 | %td= employee.personal_email 18 | %tr 19 | %th 銀行帳戶 20 | %td= employee.bank_account 21 | %th 轉帳方式 22 | %td= employee.given_bank_transfer_type 23 | -if employee.b2b? 24 | %tr 25 | %th 非個人所得 26 | %td{colspan: 3} 付款對象可開立發票,不列入勞務報酬 27 | -------------------------------------------------------------------------------- /app/services/statement_service/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module StatementService 3 | class Update 4 | include Callable 5 | attr_reader :statement, :params 6 | 7 | def initialize(statement, params) 8 | @statement = statement 9 | @params = params 10 | end 11 | 12 | def call 13 | params["correction"] = correction 14 | statement.update(params) 15 | end 16 | 17 | private 18 | 19 | def correction 20 | sum = 0 21 | params["corrections_attributes"].each do |row| 22 | sum += row[1][:amount].to_i if row[1][:_destroy] == "false" 23 | end 24 | sum 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/holidays/2023.yml: -------------------------------------------------------------------------------- 1 | holidays: 2 | 1: 3 | - 2 # 元旦 4 | - 20 # 春節彈性 5 | - 23 # 春節 6 | - 24 # 春節 7 | - 25 # 春節 8 | - 26 # 春節 9 | - 27 # 春節彈性 10 | 11 | 2: 12 | - 27 # 228 彈性 13 | - 28 # 228 14 | 4: 15 | - 3 # 清明彈性 16 | - 4 # 清明 17 | - 5 # 清明 18 | 5: 19 | - 1 # 勞動節 20 | 6: 21 | - 22 # 端午節 22 | - 23 # 端午節彈性 23 | 9: 24 | - 29 # 中秋節 25 | 10: 26 | - 9 # 國慶日彈性 27 | - 10 # 國慶日 28 | makeup_days: 29 | 1: 30 | - 7 # 春節補班 31 | 2: 32 | - 4 # 春節補班 33 | - 18 # 228 補班 34 | 3: 35 | - 25 # 清明補班 36 | 6: 37 | - 17 # 端午補班 38 | 9: 39 | - 23 # 雙十補班 40 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/company_withhold_bonus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 二代健保:公司代扣總額(獎金) 3 | module HealthInsuranceService 4 | class CompanyWithholdBonus 5 | include Callable 6 | 7 | attr_reader :year, :month 8 | 9 | def initialize(year, month) 10 | @year = year 11 | @month = month 12 | end 13 | 14 | def call 15 | premium 16 | end 17 | 18 | private 19 | 20 | def premium 21 | PayrollDetail.monthly_excess_payment(year: year, month: month).reduce(0) do |sum, row| 22 | sum + HealthInsuranceService::BonusIncome.call(Payroll.find(row.payroll_id)) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db/migrate/20180105160319_create_salaries.rb: -------------------------------------------------------------------------------- 1 | class CreateSalaries < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :salaries do |t| 4 | t.string :role 5 | t.string :tax_code, default: 50 6 | t.integer :monthly_wage, default: 0 7 | t.integer :hourly_wage, default: 0 8 | t.date :effective_date 9 | t.integer :equipment_subsidy, default: 0 10 | t.integer :commuting_subsidy, default: 0 11 | t.integer :supervisor_allowance, default: 0 12 | t.integer :labor_insurance, default: 0 13 | t.integer :health_insurance, default: 0 14 | 15 | t.integer :employee_id 16 | t.timestamps 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/views/reports_v03.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | statements.id AS statement_id, 5 | employees.name, 6 | payrolls.year, 7 | payrolls.month, 8 | salaries.tax_code, 9 | statements.amount, 10 | statements.irregular_income, 11 | SUM(corrections.amount) AS correction 12 | FROM employees 13 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 14 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 15 | INNER JOIN statements ON payrolls.id = statements.payroll_id 16 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 17 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 18 | -------------------------------------------------------------------------------- /app/helpers/payrolls_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module PayrollsHelper 3 | def init_year 4 | q_year.between?(2014, this_year + 1) ? q_year : this_year 5 | end 6 | 7 | def init_month 8 | q_month.between?(1, 12) ? q_month : this_month 9 | end 10 | 11 | def init_button_text 12 | "產生薪資單:#{init_year}-#{sprintf('%02d', init_month)}" 13 | end 14 | 15 | private 16 | 17 | def this_year 18 | Time.zone.today.year 19 | end 20 | 21 | def this_month 22 | Time.zone.today.month 23 | end 24 | 25 | def q_year 26 | params.dig(:query, :year_eq).to_i 27 | end 28 | 29 | def q_month 30 | params.dig(:query, :month_eq).to_i 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /db/views/reports_v04.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | employees.name, 5 | payrolls.year, 6 | payrolls.month, 7 | salaries.tax_code, 8 | statements.amount, 9 | statements.irregular_income, 10 | payrolls.festival_bonus, 11 | payrolls.festival_type, 12 | SUM(corrections.amount) AS correction 13 | FROM employees 14 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 15 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 16 | INNER JOIN statements ON payrolls.id = statements.payroll_id 17 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 18 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 19 | -------------------------------------------------------------------------------- /app/views/employees/_terms.haml: -------------------------------------------------------------------------------- 1 | %table.table 2 | %tr 3 | %th.col-xs-1 # 4 | %th.col-xs-2 起始日期 5 | %th.col-xs-2 結束日期 6 | %th.col-xs-6 操作 7 | - terms.each do |term| 8 | %tr 9 | %td= term.id 10 | %td= term.start_date 11 | %td= term.end_date 12 | %td 13 | = link_to "新增薪資", new_employee_salary_path(employee_id: term.employee.id, term_id: term.id), class: "btn btn-xs btn-primary" 14 | = link_to "修改", edit_employee_term_path(employee_id: term.employee.id, id: term.id), class: "btn btn-xs btn-default" 15 | = link_to "刪除", employee_term_path(employee_id: term.employee.id, id: term.id), method: :delete, data: { confirm: "確定刪除?" }, class: "btn btn-xs btn-danger" 16 | -------------------------------------------------------------------------------- /app/services/income_tax_service/professional_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class ProfessionalService 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | end 12 | 13 | # 執行業務所得超過兩萬元 需代扣 10% 所得稅 14 | def call 15 | return 0 if payroll.salary.split? 16 | return 0 unless taxable? 17 | (taxable_income * rate).ceil 18 | end 19 | 20 | private 21 | 22 | def taxable? 23 | taxable_income > exemption 24 | end 25 | 26 | def exemption 27 | 20000 28 | end 29 | 30 | def rate 31 | 0.1 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/decorators/payroll_decorator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class PayrollDecoratorTest < ActiveSupport::TestCase 5 | def test_role 6 | assert_equal "正職", decorated_subject.role 7 | end 8 | 9 | def test_payment_period 10 | assert_equal "2018-09", decorated_subject.payment_period 11 | end 12 | 13 | private 14 | 15 | def decorated_subject 16 | ActiveDecorator::Decorator.instance.decorate( 17 | build( 18 | :payroll, 19 | year: 2018, 20 | month: 9, 21 | salary: create(:salary, role: "regular", employee: build(:employee), term: build(:term)), 22 | employee: build(:employee) 23 | ) 24 | ) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/services/report_service/vacation_refund_cell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class VacationRefundCell 4 | include Callable 5 | 6 | attr_reader :payroll 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | end 11 | 12 | def call 13 | { 14 | employee_id: payroll.employee_id, 15 | name: payroll.employee_name, 16 | period: period, 17 | description: "特休折現", 18 | amount: CalculationService::VacationRefund.call(payroll), 19 | payroll_id: payroll.id, 20 | } 21 | end 22 | 23 | private 24 | 25 | def period 26 | "#{payroll.year}-#{sprintf('%02d', payroll.month)}" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/services/salary_service/term_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SalaryService 3 | class TermFinder 4 | include Callable 5 | 6 | attr_reader :salary, :terms 7 | 8 | def initialize(salary) 9 | @salary = salary 10 | @terms = salary.employee.terms 11 | end 12 | 13 | def call 14 | find_term 15 | end 16 | 17 | private 18 | 19 | def find_term 20 | terms.map do |term| 21 | return term if salary_match?(term) 22 | end 23 | end 24 | 25 | def salary_match?(term) 26 | salary.effective_date.between? term.start_date, end_date(term) 27 | end 28 | 29 | def end_date(term) 30 | term.end_date || Date.today 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/services/income_tax_service/regular_employee_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class RegularEmployeeTest < ActiveSupport::TestCase 6 | def test_has_insuranced_salary_and_irregular_income 7 | IncomeTaxService::InsurancedSalary.expects(:call).returns(3000) 8 | IncomeTaxService::IrregularIncome.expects(:call).returns(5500) 9 | assert_equal 8500, IncomeTaxService::RegularEmployee.call(subject) 10 | end 11 | 12 | private 13 | 14 | def subject 15 | build( 16 | :payroll, 17 | year: 2018, 18 | month: 5, 19 | salary: build(:salary), 20 | employee: build(:employee) 21 | ) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Fortuna 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 5.1 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration should go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded. 17 | config.time_zone = "Taipei" 18 | config.i18n.default_locale = :"zh-TW" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/services/business_calendar_days_serivce_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class BusinessCalendarDaysServiceTest < ActiveSupport::TestCase 5 | def test_2018_02 6 | assert_equal 15, BusinessCalendarDaysService.call(Date.new(2018, 2, 1), Date.new(2018, 2, 28)) 7 | end 8 | 9 | def test_2018_07 10 | assert_equal 22, BusinessCalendarDaysService.call(Date.new(2018, 7, 1), Date.new(2018, 7, 31)) 11 | end 12 | 13 | def test_2018_02_range 14 | assert_equal 5, BusinessCalendarDaysService.call(Date.new(2018, 2, 21), Date.new(2018, 2, 28)) 15 | end 16 | 17 | # 無補班日 18 | def test_2015_05 19 | assert_equal 20, BusinessCalendarDaysService.call(Date.new(2015, 5, 1), Date.new(2015, 5, 31)) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/views/employees/_payrolls.haml: -------------------------------------------------------------------------------- 1 | %table.table 2 | %tr 3 | %th # 4 | %th 期間 5 | %th 身份 6 | %th 實發薪資 7 | %th 申報所得 8 | %th 操作 9 | - payrolls.each do |payroll| 10 | - if payroll.statement 11 | %tr 12 | %td= payroll.id 13 | %td= payroll.payment_period 14 | %td= payroll.role 15 | = payroll.statement.paid_amount_cell 16 | = payroll.statement.declared_income_cell 17 | %td 18 | = link_to "修改明細", edit_payroll_path(payroll), class: "btn btn-xs btn-default" 19 | = link_to "報表校正", edit_statement_path(payroll.statement), class: "btn btn-xs btn-warning" 20 | = link_to "刪除", payroll_path(payroll), method: :delete, data: { confirm: "確定刪除?" }, class: "btn btn-xs btn-danger" 21 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | Rails.application.config.assets.precompile += %w( 15 | payrolls.css employees.css salaries.css statements.css reports.css terms.css 16 | ) 17 | -------------------------------------------------------------------------------- /app/services/payroll_init_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class PayrollInitService 3 | include Callable 4 | 5 | attr_reader :year, :month, :employee_id, :salary_id 6 | 7 | def initialize(year, month, employee_id, salary_id) 8 | @year = year.to_i 9 | @month = month.to_i 10 | @employee_id = employee_id 11 | @salary_id = salary_id 12 | end 13 | 14 | def call 15 | payroll 16 | sync_statement 17 | end 18 | 19 | private 20 | 21 | def payroll 22 | @payroll ||= Payroll.find_or_create_by( 23 | year: year, 24 | month: month, 25 | employee_id: employee_id, 26 | salary_id: salary_id 27 | ) 28 | end 29 | 30 | def sync_statement 31 | StatementService::Builder.call(payroll) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/services/calculation_service/sicktime_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module CalculationService 5 | class SicktimeTest < ActiveSupport::TestCase 6 | def test_sicktime_with_fulltime_salary 7 | salary = build(:salary, monthly_wage: 36000) 8 | payroll = build(:payroll, salary: salary, sicktime_hours: 1) 9 | assert_equal 75, CalculationService::Sicktime.call(payroll) 10 | end 11 | 12 | def test_sicktime_with_parttime_salary 13 | salary = build(:salary, monthly_wage: 24000, equipment_subsidy: 800, monthly_wage_adjustment: 0.6) 14 | payroll = build(:payroll, salary: salary, sicktime_hours: 1) 15 | assert_equal 85, CalculationService::Sicktime.call(payroll) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/services/income_tax_service/exemption.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class Exemption 4 | include Callable 5 | 6 | attr_reader :date 7 | 8 | # 薪資所得扣繳稅額表無配偶及受扶養親屬者之起扣標準(也太長) 9 | def initialize(year, month) 10 | @date = Date.new(year, month, 1) 11 | end 12 | 13 | def call 14 | exemption_by_date 15 | end 16 | 17 | private 18 | 19 | def exemption_by_date 20 | if date >= Date.new(2024, 1, 1) 21 | 88500 22 | elsif date >= Date.new(2022, 1, 1) 23 | 86000 24 | elsif date >= Date.new(2018, 1, 1) 25 | 84500 26 | elsif date >= Date.new(2017, 1, 1) 27 | 73500 28 | else 29 | 73000 # 再往下寫沒意義 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/services/report_service/overtime_cell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class OvertimeCell 4 | include Callable 5 | 6 | attr_reader :overtime, :employee 7 | 8 | def initialize(overtime) 9 | @overtime = overtime 10 | @employee = overtime.employee 11 | end 12 | 13 | def call 14 | { 15 | employee_id: employee.id, 16 | name: employee.name, 17 | period: period, 18 | description: "加班費", 19 | amount: CalculationService::Overtime.call(overtime), 20 | payroll_id: overtime.payroll_id, 21 | } 22 | end 23 | 24 | private 25 | 26 | def period 27 | "#{overtime.payroll_year}-#{sprintf('%02d', overtime.payroll_month)}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /db/views/reports_v05.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | employees.name, 5 | employees.id_number, 6 | employees.residence_address, 7 | payrolls.year, 8 | payrolls.month, 9 | salaries.tax_code, 10 | statements.amount, 11 | statements.irregular_income, 12 | payrolls.festival_bonus, 13 | payrolls.festival_type, 14 | SUM(corrections.amount) AS correction 15 | FROM employees 16 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 17 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 18 | INNER JOIN statements ON payrolls.id = statements.payroll_id 19 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 20 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 21 | -------------------------------------------------------------------------------- /test/models/extra_entry_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class ExtraEntryTest < ActiveSupport::TestCase 5 | def test_scope_yearly_subsidy_report 6 | prepare_scope_yearly_subsidy_report(year: 2017, subsidies: 5) 7 | prepare_scope_yearly_subsidy_report(year: 2018, subsidies: 3) 8 | 9 | assert ExtraEntry.yearly_subsidy_report(2017).count, 5 10 | assert ExtraEntry.yearly_subsidy_report(2018).count, 3 11 | end 12 | 13 | private 14 | 15 | def prepare_scope_yearly_subsidy_report(year:, subsidies:) 16 | create(:payroll, year: year, salary: build(:salary), employee: build(:employee)) do |payroll| 17 | subsidies.times { create(:extra_entry, amount: 1, income_type: "subsidy", payroll: payroll) } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/services/report_service/service_income_cell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class ServiceIncomeCell 4 | include Callable 5 | 6 | attr_reader :data, :year, :month 7 | 8 | def initialize(data, year, month) 9 | @data = data 10 | @year = year 11 | @month = month 12 | end 13 | 14 | def call 15 | if month.zero? 16 | shift_to_previous_year 17 | else 18 | shift_to_previous_month 19 | end 20 | end 21 | 22 | private 23 | 24 | def shift_to_previous_year 25 | data.select { |cell| (cell.month == 12) && (cell.year == year - 1) }.first 26 | end 27 | 28 | def shift_to_previous_month 29 | data.select { |cell| cell.month == month }.first 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/services/income_tax_service/exemption_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class ExemptionTest < ActiveSupport::TestCase 6 | def test_after_2024 7 | assert_equal 88500, IncomeTaxService::Exemption.call(2024, 1) 8 | end 9 | 10 | def test_after_2022 11 | assert_equal 86000, IncomeTaxService::Exemption.call(2022, 1) 12 | end 13 | 14 | def test_after_2018 15 | assert_equal 84500, IncomeTaxService::Exemption.call(2018, 1) 16 | end 17 | 18 | def test_after_2017 19 | assert_equal 73500, IncomeTaxService::Exemption.call(2017, 1) 20 | end 21 | 22 | def test_before_2017 23 | assert_equal 73000, IncomeTaxService::Exemption.call(2016, 12) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/helpers/payrolls_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class PayrollsHelperTest < ActionView::TestCase 5 | def test_with_params 6 | prepare_params(year: 2017, month: 7) 7 | assert_equal 2017, init_year 8 | assert_equal 7, init_month 9 | assert_equal "產生薪資單:2017-07", init_button_text 10 | end 11 | 12 | def test_without_params 13 | Timecop.freeze(Date.new(2015, 3)) do 14 | assert_equal 2015, init_year 15 | assert_equal 3, init_month 16 | assert_equal "產生薪資單:2015-03", init_button_text 17 | end 18 | end 19 | 20 | private 21 | 22 | def prepare_params(year:, month:) 23 | @controller = ApplicationController.new 24 | @controller.send(:params=, query: { year_eq: year, month_eq: month }) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/services/income_tax_service/dispatcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class Dispatcher 4 | include Callable 5 | 6 | attr_reader :payroll, :salary 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | @salary = payroll.salary 11 | end 12 | 13 | def call 14 | return 0 if payroll.employee.b2b? 15 | dispatch 16 | end 17 | 18 | private 19 | 20 | def dispatch 21 | if salary.professional_service? 22 | IncomeTaxService::ProfessionalService.call(payroll) 23 | elsif salary.parttime_income_uninsured_for_labor? 24 | IncomeTaxService::UninsurancedSalary.call(payroll) 25 | else 26 | IncomeTaxService::RegularEmployee.call(payroll) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /db/views/reports_v07.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | employees.name, 5 | employees.id_number, 6 | employees.residence_address, 7 | payrolls.year, 8 | payrolls.month, 9 | salaries.tax_code, 10 | statements.amount, 11 | statements.subsidy_income, 12 | payrolls.festival_bonus, 13 | payrolls.festival_type, 14 | SUM(corrections.amount) AS correction 15 | FROM employees 16 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 17 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 18 | INNER JOIN statements ON payrolls.id = statements.payroll_id 19 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 20 | WHERE employees.b2b = false 21 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 22 | -------------------------------------------------------------------------------- /db/views/reports_v06.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | employees.name, 5 | employees.id_number, 6 | employees.residence_address, 7 | payrolls.year, 8 | payrolls.month, 9 | salaries.tax_code, 10 | statements.amount, 11 | statements.irregular_income, 12 | payrolls.festival_bonus, 13 | payrolls.festival_type, 14 | SUM(corrections.amount) AS correction 15 | FROM employees 16 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 17 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 18 | INNER JOIN statements ON payrolls.id = statements.payroll_id 19 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 20 | WHERE employees.b2b = false 21 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 22 | -------------------------------------------------------------------------------- /test/services/calculation_service/vacation_refund_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module CalculationService 5 | class VacationRefundTest < ActiveSupport::TestCase 6 | def test_refund_with_fulltime_salary 7 | salary = build(:salary, monthly_wage: 36000) 8 | payroll = build(:payroll, salary: salary, vacation_refund_hours: 1) 9 | assert_equal 150, CalculationService::VacationRefund.call(payroll) 10 | end 11 | 12 | def test_refund_with_parttime_salary 13 | salary = build(:salary, monthly_wage: 24000, equipment_subsidy: 800, monthly_wage_adjustment: 0.6) 14 | payroll = build(:payroll, salary: salary, vacation_refund_hours: 1) 15 | assert_equal 170, CalculationService::VacationRefund.call(payroll) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's 5 | // vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery3 14 | //= require jquery_ujs 15 | //= require turbolinks 16 | //= require cocoon 17 | //= require_tree . 18 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/dispatcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module HealthInsuranceService 3 | class Dispatcher 4 | include Callable 5 | 6 | attr_reader :payroll, :salary 7 | 8 | def initialize(payroll) 9 | @payroll = payroll 10 | @salary = payroll.salary 11 | end 12 | 13 | def call 14 | return 0 if payroll.employee.b2b? 15 | dispatch 16 | end 17 | 18 | private 19 | 20 | def dispatch 21 | if salary.professional_service? 22 | HealthInsuranceService::ProfessionalService.call(payroll) 23 | elsif salary.parttime_income_uninsured_for_health? 24 | HealthInsuranceService::ParttimeIncome.call(payroll) 25 | else 26 | HealthInsuranceService::BonusIncome.call(payroll) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/statements/_list.haml: -------------------------------------------------------------------------------- 1 | %table.table 2 | %tr.warning 3 | %th 姓名 4 | %th 身份 5 | %th 計算月份 6 | %th 實發薪資 7 | %th 申報所得 8 | %th 操作 9 | - statements.each do |statement| 10 | %tr 11 | %td= statement.employee_name 12 | %td= statement.payroll.role 13 | %td= statement.payroll.payment_period 14 | = statement.paid_amount_cell 15 | = statement.declared_income_cell 16 | %td 17 | = link_to "員工資料", employee_path(statement.payroll.employee), class: "btn btn-xs btn-default" 18 | = link_to "薪資單", statement_path(statement), class: "btn btn-xs btn-info" 19 | 20 | %tr.info 21 | %th 總額 22 | %td 23 | %td 24 | %td= number_with_delimiter statements.map { |statement| statement.amount }.reduce(:+) 25 | %td 26 | %td 27 | 28 | = paginate statements 29 | -------------------------------------------------------------------------------- /app/services/income_tax_service/irregular_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class IrregularIncome 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | end 12 | 13 | # 非經常性給予超過免稅額 需代扣 5% 所得稅 14 | def call 15 | return 0 unless taxable? 16 | (irregular_income * rate).ceil 17 | end 18 | 19 | private 20 | 21 | def taxable? 22 | irregular_income > exemption 23 | end 24 | 25 | def irregular_income 26 | bonus_income + payroll.extra_income_of(:salary) 27 | end 28 | 29 | def exemption 30 | IncomeTaxService::Exemption.call(payroll.year, payroll.month) 31 | end 32 | 33 | def rate 34 | 0.05 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/initializers/wicked_pdf.rb: -------------------------------------------------------------------------------- 1 | # WickedPDF Global Configuration 2 | # 3 | # Use this to set up shared configuration options for your entire application. 4 | # Any of the configuration options shown here can also be applied to single 5 | # models by passing arguments to the `render :pdf` call. 6 | # 7 | # To learn more, check out the README: 8 | # 9 | # https://github.com/mileszs/wicked_pdf/blob/master/README.md 10 | 11 | WickedPdf.config = { 12 | # Path to the wkhtmltopdf executable: This usually isn't needed if using 13 | # one of the wkhtmltopdf-binary family of gems. 14 | # exe_path: '/usr/local/bin/wkhtmltopdf', 15 | # or 16 | # exe_path: Gem.bin_path('wkhtmltopdf-binary', 'wkhtmltopdf') 17 | 18 | # Layout file to be used for all PDFs 19 | # (but can be overridden in `render :pdf` calls) 20 | # layout: 'pdf.html', 21 | } 22 | -------------------------------------------------------------------------------- /db/views/reports_v09.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | employees.name, 5 | employees.id_number, 6 | employees.residence_address, 7 | payrolls.year, 8 | payrolls.month, 9 | salaries.tax_code, 10 | statements.amount, 11 | statements.subsidy_income, 12 | statements.gain, 13 | statements.loss, 14 | statements.correction, 15 | payrolls.festival_bonus, 16 | payrolls.festival_type 17 | FROM employees 18 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 19 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 20 | INNER JOIN statements ON payrolls.id = statements.payroll_id 21 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 22 | WHERE employees.b2b = false 23 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 24 | -------------------------------------------------------------------------------- /app/services/report_service/extra_entry_cell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class ExtraEntryCell 4 | include Callable 5 | 6 | attr_reader :entry, :payroll 7 | 8 | def initialize(entry) 9 | @entry = entry 10 | @payroll = entry.payroll 11 | end 12 | 13 | def call 14 | { 15 | employee_id: employee_id, 16 | name: name, 17 | period: period, 18 | description: entry.title, 19 | amount: entry.amount, 20 | payroll_id: payroll.id, 21 | } 22 | end 23 | 24 | private 25 | 26 | def employee_id 27 | payroll.employee_id 28 | end 29 | 30 | def name 31 | payroll.employee_name 32 | end 33 | 34 | def period 35 | "#{payroll.year}-#{sprintf('%02d', payroll.month)}" 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fortuna 5 | <%= csrf_meta_tags %> 6 | 7 | <%= stylesheet_link_tag "application", media: "all", "data-turbolinks-track": "reload" %> 8 | <%= stylesheet_link_tag controller_name %> 9 | <%= javascript_include_tag "application", "data-turbolinks-track": "reload" %> 10 | 11 | 12 | 13 | 19 | <%= yield %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /db/views/reports_v08.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | employees.id AS employee_id, 3 | payrolls.id AS payroll_id, 4 | employees.name, 5 | employees.id_number, 6 | employees.residence_address, 7 | payrolls.year, 8 | payrolls.month, 9 | salaries.tax_code, 10 | statements.amount, 11 | statements.subsidy_income, 12 | statements.gain, 13 | statements.loss, 14 | payrolls.festival_bonus, 15 | payrolls.festival_type, 16 | SUM(corrections.amount) AS correction 17 | FROM employees 18 | INNER JOIN payrolls ON employees.id = payrolls.employee_id 19 | INNER JOIN salaries ON salaries.id = payrolls.salary_id 20 | INNER JOIN statements ON payrolls.id = statements.payroll_id 21 | LEFT OUTER JOIN corrections ON statements.id = corrections.statement_id 22 | WHERE employees.b2b = false 23 | GROUP BY employees.id, payrolls.id, statements.id, salaries.tax_code 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | !/tmp/pdfs 21 | tmp/pdfs/* 22 | 23 | /coverage/* 24 | 25 | /node_modules 26 | /yarn-error.log 27 | 28 | /config/database.yml 29 | /config/secrets.yml 30 | 31 | /lib/tasks/5xruby.rake 32 | /lib/tasks/5xruby.csv 33 | 34 | .byebug_history 35 | 36 | # Ignore application configuration 37 | /config/application.yml 38 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /app/services/format_service/statement_gain_loss_columns.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FormatService 3 | class StatementGainLossColumns 4 | include Callable 5 | 6 | attr_reader :gain, :loss 7 | 8 | def initialize(gain, loss) 9 | @gain = hash_to_array(gain) 10 | @loss = hash_to_array(loss) 11 | end 12 | 13 | def call 14 | if gain_size > loss_size 15 | loss.fill(loss_size..gain_size - 1) { { "": nil } } 16 | else 17 | gain.fill(gain_size..loss_size - 1) { { "": nil } } 18 | end 19 | gain.zip(loss) 20 | end 21 | 22 | private 23 | 24 | def hash_to_array(hash) 25 | array = [] 26 | hash.map { |key, value| array << { "#{key}": value } } 27 | array 28 | end 29 | 30 | def gain_size 31 | gain.size 32 | end 33 | 34 | def loss_size 35 | loss.size 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/decorators/salary_decorator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class SalaryDecoratorTest < ActiveSupport::TestCase 5 | def test_monthly_wage 6 | subject = decorated_subject(monthly_wage: 50000, split: true) 7 | assert_equal "月薪", subject.wage_type 8 | assert_equal 50000, subject.wage 9 | assert_equal "拆單", subject.split_status 10 | end 11 | 12 | def test_hourly_wage 13 | subject = decorated_subject(hourly_wage: 200, split: false) 14 | assert_equal "時薪", subject.wage_type 15 | assert_equal 200, subject.wage 16 | assert_equal "不拆單", subject.split_status 17 | end 18 | 19 | private 20 | 21 | def decorated_subject(monthly_wage: 0, hourly_wage: 0, split:) 22 | ActiveDecorator::Decorator.instance.decorate( 23 | build(:salary, monthly_wage: monthly_wage, hourly_wage: hourly_wage, split: split) 24 | ) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/decorators/statement_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module StatementDecorator 3 | include ActionView::Helpers::TagHelper 4 | include ActionView::Helpers::NumberHelper 5 | include ActionView::Context 6 | 7 | def declared_income 8 | amount - subsidy_income + correction 9 | end 10 | 11 | def declared_income_cell 12 | content_tag :td, class: declared_income_style do 13 | number_with_delimiter(declared_income) 14 | end 15 | end 16 | 17 | def paid_amount 18 | amount + correction 19 | end 20 | 21 | def paid_amount_cell 22 | content_tag :td, class: paid_amount_style do 23 | number_with_delimiter(paid_amount) 24 | end 25 | end 26 | 27 | private 28 | 29 | def declared_income_style 30 | subsidy_income.positive? ? "highlight" : nil 31 | end 32 | 33 | def paid_amount_style 34 | correction.zero? ? nil : "highlight" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/views/employees/_salaries.haml: -------------------------------------------------------------------------------- 1 | %table.table 2 | %tr 3 | %th.col-xs-1 # 4 | %th.col-xs-1 生效日期 5 | %th.col-xs-1 薪資 6 | %th.col-xs-1 身份 7 | %th.col-xs-2 合作期間 8 | %th.col-xs-6 操作 9 | - salaries.each do |salary| 10 | %tr 11 | %td= salary.id 12 | %td= salary.effective_date 13 | %td= "#{salary.wage_type} #{salary.wage} 元" 14 | %td= salary.given_role 15 | %td= "#{salary.term.start_date} ~ #{salary.term.end_date}" 16 | %td 17 | = link_to "檢視", employee_salary_path(employee_id: salary.employee.id, id: salary.id), class: "btn btn-xs btn-default" 18 | = link_to "修改", edit_employee_salary_path(employee_id: salary.employee.id, id: salary.id), class: "btn btn-xs btn-default" 19 | = link_to "刪除", employee_salary_path(employee_id: salary.employee.id, id: salary.id), method: :delete, data: { confirm: "確定刪除?" }, class: "btn btn-xs btn-danger" 20 | -------------------------------------------------------------------------------- /app/views/reports/monthly.haml: -------------------------------------------------------------------------------- 1 | %h3 #{params[:year]}-#{sprintf('%02d', params[:month])} #{t(action_name, scope: :reports)} 2 | %p= link_to "匯出 CSV", monthly_reports_path(params[:year], params[:month], format: :csv), class: "btn btn-m btn-primary" 3 | 4 | - if @report.any? 5 | %table.table 6 | %tr 7 | %th 姓名 8 | - @report[:gain_keys].each do |key| 9 | %th= key 10 | - @report[:loss_keys].each do |key| 11 | %th= key 12 | - @report[:data].each do |row| 13 | %tr 14 | %td= row[:name] 15 | - row[:gain].each do |gain| 16 | %td= number_with_delimiter(gain[1]) 17 | - row[:loss].each do |loss| 18 | %td= number_with_delimiter(loss[1]) 19 | %tr 20 | %th 小計 21 | - @report[:gain_sum].each do |sum| 22 | %th= number_with_delimiter(sum[1]) 23 | - @report[:loss_sum].each do |sum| 24 | %th= number_with_delimiter(sum[1]) 25 | -------------------------------------------------------------------------------- /config/minimum_wage.yml: -------------------------------------------------------------------------------- 1 | - start: "0000-01-01" 2 | end: "2013-12-31" 3 | value: 18780 4 | - start: "2014-01-01" 5 | end: "2014-06-30" 6 | value: 19047 7 | - start: "2014-07-01" 8 | end: "2016-09-30" 9 | value: 19273 10 | - start: "2016-10-01" 11 | end: "2016-12-31" 12 | value: 20008 13 | - start: "2017-01-01" 14 | end: "2017-12-31" 15 | value: 21009 16 | - start: "2018-01-01" 17 | end: "2018-12-31" 18 | value: 22000 19 | - start: "2019-01-01" 20 | end: "2019-12-31" 21 | value: 23100 22 | - start: "2020-01-01" 23 | end: "2020-12-31" 24 | value: 23800 25 | - start: "2021-01-01" 26 | end: "2021-12-31" 27 | value: 24000 28 | - start: "2022-01-01" 29 | end: "2022-12-31" 30 | value: 25250 31 | - start: "2023-01-01" 32 | end: "2023-12-31" 33 | value: 26400 34 | - start: "2024-01-01" 35 | end: "2024-12-31" 36 | value: 27470 37 | - start: "2025-01-01" 38 | end: "infinity" 39 | value: 28590 40 | -------------------------------------------------------------------------------- /lib/tasks/sample.csv: -------------------------------------------------------------------------------- 1 | name,start_date,end_date,id_number,birthday,residence_address,company_email,personal_email,bank_transfer_type,bank_account,effective_date,role,monthly_wage,hourly_wage,tax_code,commuting_subsidy,equipment_subsidy,supervisor_allowance,labor_insurance,health_insurance,insured_for_labor,insured_for_health,cycle 2 | 李小華,2019-03-01,2019-03-31,A987654321,1999-04-04,,,,salary,,2018-01-01,contractor,50000,0,50,0,0,0,0,0,0,0,business 3 | 王小明,2019-03-01,2019-03-31,A123456789,1991-01-01,,,,salary,,2018-01-01,regular,32000,0,50,0,800,0,700,469,33300,33300,normal 4 | 陳小花,2019-03-01,2019-03-31,A222111333,2000-03-01,,,,normal,,2018-01-01,parttime,0,150,50,3000,0,0,233,0,11000,0,normal 5 | 陳小如,2019-03-01,2019-03-31,A222111333,2000-03-01,,,,normal,,2018-01-01,advisor,0,2000,9a,0,0,0,233,0,11000,0,normal 6 | 王小明,2019-03-01,2019-03-31,A123456789,1991-01-01,,,,salary,,2018-01-01,regular,60000,0,50,0,800,0,700,469,33300,33300,normal 7 | -------------------------------------------------------------------------------- /test/services/health_insurance_service/company_withhold_bonus_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module HealthInsuranceService 5 | class CompanyWithholdBonusTest < ActiveSupport::TestCase 6 | def test_premium 7 | prepare_employee(excess: 20000, insured: 24000) 8 | prepare_employee(excess: 150000, insured: 36000) 9 | assert_equal 115, HealthInsuranceService::CompanyWithholdBonus.call(2016, 1) 10 | end 11 | 12 | private 13 | 14 | def prepare_employee(insured:, excess:) 15 | employee = build(:employee) 16 | build( 17 | :payroll, 18 | year: 2016, 19 | month: 1, 20 | salary: build(:salary, insured_for_health: insured, tax_code: "50", employee: employee, term: build(:term)), 21 | employee: employee 22 | ) { |payroll| create(:statement, excess_income: excess, payroll: payroll) } 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/professional_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 二代健保:執行業務所得 單次給付超過兩萬元 3 | module HealthInsuranceService 4 | class ProfessionalService 5 | include Callable 6 | include Calculatable 7 | 8 | attr_reader :payroll 9 | 10 | def initialize(payroll) 11 | @payroll = payroll 12 | end 13 | 14 | def call 15 | return 0 if payroll.salary.split? 16 | return 0 unless eligible? 17 | premium 18 | end 19 | 20 | private 21 | 22 | def premium 23 | (premium_base * rate).ceil 24 | end 25 | 26 | def rate 27 | HealthInsuranceService::SupplementPremiumRate.call(payroll.year, payroll.month) 28 | end 29 | 30 | def eligible? 31 | taxable_income > exemption 32 | end 33 | 34 | def premium_base 35 | taxable_income 36 | end 37 | 38 | def exemption 39 | 20000 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/lib/collection_translatable_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class CollectionTranslatableTest < ActiveSupport::TestCase 5 | def test_translate_given_values 6 | subject = DummyObject.new("apple") 7 | assert_equal "蘋果", subject.given_fruit 8 | end 9 | 10 | def test_methods_not_prefixed_with_given 11 | subject = DummyObject.new 12 | assert_raises NoMethodError do 13 | subject.eat_fruit 14 | end 15 | end 16 | 17 | def test_respond_to_missing 18 | subject = DummyObject.new 19 | assert subject.respond_to? :given_anything 20 | assert_not subject.respond_to? :eat_fruit 21 | end 22 | 23 | class DummyObject 24 | include ActiveModel::Model 25 | include CollectionTranslatable 26 | 27 | attr_reader :fruit 28 | 29 | FRUIT = { "蘋果": "apple" }.freeze 30 | 31 | def initialize(fruit = nil) 32 | @fruit = fruit 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/services/salary_service/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Salary 和 Payroll 是透過日期重疊的間接關聯,因此起薪日變動後需要補正與 Payroll 的關聯 3 | module SalaryService 4 | class Base 5 | include Callable 6 | 7 | attr_reader :salary, :params 8 | 9 | def initialize(salary, params) 10 | @salary = salary 11 | @params = params 12 | end 13 | 14 | protected 15 | 16 | def effective_date 17 | Date.new(params["effective_date(1i)"].to_i, params["effective_date(2i)"].to_i, params["effective_date(3i)"].to_i) 18 | end 19 | 20 | def payrolls 21 | salary.employee.payrolls.details 22 | end 23 | 24 | # TODO: 只修正有影響到的部分 25 | def sync_payroll_relations 26 | payrolls.map { |payroll| SalaryService::SyncPayrollRelations.call(payroll) } 27 | end 28 | 29 | # TODO: 只修正有影響到的部分 30 | def sync_statements 31 | payrolls.map { |payroll| StatementService::Builder.call(payroll) } 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/services/calculation_service/leavetime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module CalculationService 3 | class Leavetime 4 | include Callable 5 | 6 | attr_reader :hours, :salary, :payroll 7 | 8 | def initialize(payroll) 9 | @hours = payroll.leavetime_hours 10 | @salary = payroll.salary 11 | @payroll = payroll 12 | end 13 | 14 | def call 15 | (hours * hourly_rate).to_i 16 | end 17 | 18 | private 19 | 20 | def hourly_rate 21 | (salary.income_with_subsidies / days_in_month / 8.0).ceil 22 | end 23 | 24 | def days_in_month 25 | if salary.business_calendar? 26 | BusinessCalendarDaysService.call(cycle_start, cycle_end) 27 | else 28 | 30 29 | end 30 | end 31 | 32 | def cycle_start 33 | Date.new(payroll.year, payroll.month, 1) 34 | end 35 | 36 | def cycle_end 37 | Date.new(payroll.year, payroll.month, -1) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/parttime_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 二代健保:兼職薪資所得 單次給付超過最低薪資 3 | module HealthInsuranceService 4 | class ParttimeIncome 5 | include Callable 6 | include Calculatable 7 | 8 | attr_reader :payroll 9 | 10 | def initialize(payroll) 11 | @payroll = payroll 12 | end 13 | 14 | def call 15 | return 0 if payroll.salary.split? 16 | return 0 unless eligible? 17 | premium 18 | end 19 | 20 | private 21 | 22 | def premium 23 | (premium_base * rate).ceil 24 | end 25 | 26 | def rate 27 | HealthInsuranceService::SupplementPremiumRate.call(payroll.year, payroll.month) 28 | end 29 | 30 | def eligible? 31 | taxable_income > exemption 32 | end 33 | 34 | def premium_base 35 | taxable_income 36 | end 37 | 38 | def exemption 39 | MinimumWageService.call(payroll.year, payroll.month) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/services/income_tax_service/uninsuranced_salary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module IncomeTaxService 3 | class UninsurancedSalary 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | end 12 | 13 | # 151 繳款書是同一欄所以合併處理 14 | def call 15 | return 0 if payroll.salary.split? 16 | salary_tax + bonus_tax 17 | end 18 | 19 | private 20 | 21 | # 兼職所得超過免稅額 需代扣 5% 所得稅 22 | def salary_tax 23 | return 0 unless taxable_income > exemption 24 | (taxable_income * rate).ceil 25 | end 26 | 27 | # 獎金所得超過免稅額 需代扣 5% 所得稅 28 | def bonus_tax 29 | return 0 unless bonus_income > exemption 30 | (bonus_income * rate).ceil 31 | end 32 | 33 | def exemption 34 | IncomeTaxService::Exemption.call(payroll.year, payroll.month) 35 | end 36 | 37 | def rate 38 | 0.05 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/services/payroll_init_regulars_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class PayrollInitRegularsServiceTest < ActiveSupport::TestCase 5 | def test_call 6 | 6.times { prepare_subject(role: "regular") } 7 | 5.times { prepare_subject(role: "contractor") } 8 | 4.times { prepare_subject(role: "boss") } 9 | 3.times { prepare_subject(role: "advisor") } 10 | 2.times { prepare_subject(role: "vendor") } 11 | prepare_subject(role: "parttime") 12 | 13 | PayrollInitService.expects(:call).times(15) 14 | assert PayrollInitRegularsService.call(2018, 1) 15 | end 16 | 17 | private 18 | 19 | def prepare_subject(role:) 20 | create(:employee) do |employee| 21 | create( 22 | :salary, 23 | effective_date: "2018-01-01", 24 | role: role, 25 | employee: employee, 26 | term: create(:term, start_date: "2018-01-01", employee: employee) 27 | ) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/employees/index.haml: -------------------------------------------------------------------------------- 1 | %h1 員工資料 2 | 3 | %p 4 | = link_to "新增員工", new_employee_path, class: "btn btn-sm btn-primary" 5 | = link_to "月薪制", employees_path, class: "btn btn-sm btn-default" 6 | = link_to "兼職外包", parttimers_employees_path, class: "btn btn-sm btn-default" 7 | = link_to "閒置", inactive_employees_path, class: "btn btn-sm btn-default" 8 | 9 | %table.table 10 | %tr 11 | %th # 12 | %th 姓名 13 | %th 身份 14 | %th 到職日期 15 | %th 操作 16 | - @result.each do |row| 17 | %tr 18 | %td= row.employee_id 19 | %td= row.name 20 | %td= row.given_role 21 | %td= row.term_start 22 | %td 23 | = link_to "檢視", employee_path(row.employee_id), class: "btn btn-xs btn-default" 24 | = link_to "修改", edit_employee_path(row.employee_id), class: "btn btn-xs btn-default" 25 | = link_to "刪除", employee_path(row.employee_id), method: :delete, data: { confirm: "確定刪除?" }, class: "btn btn-xs btn-danger" 26 | 27 | = paginate @result 28 | -------------------------------------------------------------------------------- /config/locales/simple_form.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | simple_form: 3 | "yes": 'Yes' 4 | "no": 'No' 5 | required: 6 | text: 'required' 7 | mark: '*' 8 | # You can uncomment the line below if you need to overwrite the whole required html. 9 | # When using html, text and mark won't be used. 10 | # html: '*' 11 | error_notification: 12 | default_message: "Please review the problems below:" 13 | # Examples 14 | # labels: 15 | # defaults: 16 | # password: 'Password' 17 | # user: 18 | # new: 19 | # email: 'E-mail to sign in.' 20 | # edit: 21 | # email: 'E-mail.' 22 | # hints: 23 | # defaults: 24 | # username: 'User name to sign in.' 25 | # password: 'No special characters, please.' 26 | # include_blanks: 27 | # defaults: 28 | # age: 'Rather not say' 29 | # prompts: 30 | # defaults: 31 | # age: 'Select your age' 32 | -------------------------------------------------------------------------------- /app/models/statement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class Statement < ApplicationRecord 3 | belongs_to :payroll 4 | 5 | has_one :employee, through: :payroll 6 | delegate :id, :name, :id_number, :bank_transfer_type, to: :employee, prefix: true 7 | 8 | has_many :corrections, dependent: :destroy 9 | accepts_nested_attributes_for :corrections, allow_destroy: true 10 | 11 | scope :paid, -> { where("statements.amount > 0") } 12 | scope :by_payroll, ->(year, month) { where(year: year, month: month) } 13 | 14 | scope :ordered, -> { 15 | includes(:payroll, :employee, :corrections, payroll: [:salary, :employee]) 16 | .order("payrolls.employee_id DESC") 17 | } 18 | 19 | def self.ransackable_attributes(auth_object = nil) 20 | %w[amount correction created_at excess_income gain id loss month payroll_id splits subsidy_income updated_at year] 21 | end 22 | 23 | def self.ransackable_associations(auth_object = nil) 24 | %w[corrections employee payroll] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/company_coverage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module HealthInsuranceService 3 | class CompanyCoverage 4 | include Callable 5 | 6 | attr_reader :year, :month 7 | 8 | def initialize(year, month) 9 | @year = year.to_i 10 | @month = month.to_i 11 | end 12 | 13 | def call 14 | (premium_base * rate).ceil 15 | end 16 | 17 | private 18 | 19 | def premium_base 20 | owner_income + excess_payments 21 | end 22 | 23 | # 負責人薪資全額 24 | # FIXME: 負責人有變動的可能 25 | def owner_income 26 | row = PayrollDetail.owner_income(year: year, month: month) 27 | row.amount - row.subsidy_income 28 | end 29 | 30 | # (負責人以外)實發薪資與投保薪資的差額 31 | def excess_payments 32 | PayrollDetail.monthly_excess_payment(year: year, month: month).sum(:excess_income) 33 | end 34 | 35 | def rate 36 | HealthInsuranceService::SupplementPremiumRate.call(year, month) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /db/migrate/20181119082055_change_float_to_decimal.rb: -------------------------------------------------------------------------------- 1 | class ChangeFloatToDecimal < ActiveRecord::Migration[5.2] 2 | def up 3 | change_column :payrolls, :parttime_hours, :decimal, default: 0 4 | change_column :payrolls, :leavetime_hours, :decimal, default: 0 5 | change_column :payrolls, :sicktime_hours, :decimal, default: 0 6 | change_column :payrolls, :vacation_refund_hours, :decimal, default: 0 7 | change_column :overtimes, :hours, :decimal, default: 0 8 | change_column :salaries, :monthly_wage_adjustment, :decimal, default: 1 9 | end 10 | 11 | def down 12 | change_column :payrolls, :parttime_hours, :float, default: 0 13 | change_column :payrolls, :leavetime_hours, :float, default: 0 14 | change_column :payrolls, :sicktime_hours, :float, default: 0 15 | change_column :payrolls, :vacation_refund_hours, :float, default: 0 16 | change_column :overtimes, :hours, :float, default: 0 17 | change_column :salaries, :monthly_wage_adjustment, :float, default: 1 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/services/minimum_wage_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class MinimumWageService 3 | include Callable 4 | 5 | attr_reader :date, :brackets 6 | 7 | def initialize(year, month) 8 | @date = Date.new(year, month, 1) 9 | @brackets = [] 10 | end 11 | 12 | def call 13 | parse_yml_data_to_brackets 14 | 15 | brackets.each do |bracket| 16 | return bracket[:minimum_wage] if bracket[:range].cover?(date) 17 | end 18 | end 19 | 20 | private 21 | 22 | def parse_yml_data_to_brackets 23 | yml_data.each do |row| 24 | d1 = Date.parse(row["start"]) 25 | d2 = end_date_infinity_check(row["end"]) 26 | brackets << { range: d1..d2, minimum_wage: row["value"] } 27 | end 28 | end 29 | 30 | def end_date_infinity_check(end_date) 31 | if end_date == "infinity" 32 | Date::Infinity.new 33 | else 34 | Date.parse(end_date) 35 | end 36 | end 37 | 38 | def yml_data 39 | YAML.load_file("#{Rails.root}/config/minimum_wage.yml") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/views/salaries/_info.haml: -------------------------------------------------------------------------------- 1 | %h1 薪資資料 2 | %table.table 3 | %tr 4 | %th.col-xs-1 起薪日期 5 | %td.col-xs-2= salary.effective_date 6 | %th.col-xs-1 所得稅別 7 | %td.col-xs-2= "#{salary.given_tax_code} (#{salary.split_status})" 8 | %tr 9 | %th 月薪 10 | %td= salary.monthly_wage 11 | %th 身份 12 | %td= salary.given_role 13 | %tr 14 | %th 時薪 15 | %td= salary.hourly_wage 16 | %th 交通津貼 17 | %td= salary.commuting_subsidy 18 | %tr 19 | %th 設備津貼 20 | %td= salary.equipment_subsidy 21 | %th 主管加給 22 | %td= salary.supervisor_allowance 23 | %tr 24 | %th 勞保投保金額 25 | %td= salary.insured_for_labor 26 | %th 勞保費 27 | %td= salary.labor_insurance 28 | %tr 29 | %th 健保投保金額 30 | %td= salary.insured_for_health 31 | %th 健保費 32 | %td= salary.health_insurance 33 | %tr 34 | %th 所得稅 35 | %td= salary.fixed_income_tax 36 | %th 37 | %td 38 | %tr 39 | %th 計算周期 40 | %td= salary.given_cycle 41 | %th 兼職調整 42 | %td= salary.monthly_wage_adjustment 43 | -------------------------------------------------------------------------------- /test/services/income_tax_service/irregular_income_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class IrregularIncomeTest < ActiveSupport::TestCase 6 | def test_income_tax_over_exemption 7 | subject = prepare_subject(festival_bonus: 50000, extra_amount: 50000) 8 | assert_equal 5000, IncomeTaxService::IrregularIncome.call(subject) 9 | end 10 | 11 | def test_income_tax_under_exemption 12 | subject = prepare_subject(extra_amount: 50000) 13 | assert_equal 0, IncomeTaxService::IrregularIncome.call(subject) 14 | end 15 | 16 | private 17 | 18 | def prepare_subject(festival_bonus: 0, extra_amount: 0) 19 | build( 20 | :payroll, 21 | year: 2018, 22 | month: 5, 23 | festival_bonus: festival_bonus, 24 | salary: build(:salary, monthly_wage: 30000), 25 | employee: build(:employee) 26 | ) { |payroll| create(:extra_entry, income_type: :salary, amount: extra_amount, payroll: payroll) } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/views/employees/_form.haml: -------------------------------------------------------------------------------- 1 | = simple_form_for employee do |f| 2 | %table.table 3 | %tr 4 | %th.col-xs-1= f.label "姓名" 5 | %td.col-xs-2= f.input_field :name 6 | %th.col-xs-1= f.label "戶籍地址" 7 | %td.col-xs-2= f.input_field :residence_address, size: 50 8 | %tr 9 | %th= f.label "身分證字號" 10 | %td= f.input_field :id_number 11 | %th= f.label "出生年月日" 12 | %td= f.input_field :birthday, start_year: Date.today.year - 65, end_year: Date.today.year - 18, include_blank: true 13 | %tr 14 | %th= f.label "公司信箱" 15 | %td= f.input_field :company_email 16 | %th= f.label "私人信箱" 17 | %td= f.input_field :personal_email 18 | %tr 19 | %th= f.label "銀行帳戶" 20 | %td= f.input_field :bank_account 21 | %th= f.label "轉帳方式" 22 | %td= f.input_field :bank_transfer_type, collection: Employee::BANK_TRANSFER_TYPE 23 | %tr 24 | %th= f.label "非個人所得" 25 | %td{colspan: 3} 26 | = f.input_field :b2b 27 | 付款對象可開立發票,不列入勞務報酬 28 | 29 | .text-center 30 | = f.submit "送出" 31 | -------------------------------------------------------------------------------- /app/views/statements/show.haml: -------------------------------------------------------------------------------- 1 | .row 2 | .col-xs-6 3 | %table.table.table-bordered 4 | %tr.active 5 | %th.col-xs-1.text-center 姓名 6 | %th.col-xs-2.text-center= @details[:name] 7 | %th.col-xs-1.text-center 計算月份 8 | %th.col-xs-2.text-center= @details[:period] 9 | = render "details", details: @details[:details] 10 | %tr.warning 11 | %th.text-center 小計 12 | %td.text-right= number_with_delimiter @details[:gain] 13 | %th.text-center 小計 14 | %td.text-right= number_with_delimiter @details[:loss] 15 | - unless @details[:correction].zero? 16 | %tr 17 | %th.text-center 系統結算 18 | %td.text-right{colspan: 3}= number_with_delimiter @details[:total] 19 | %tr 20 | %th.text-center 報表校正 21 | %td.text-right{colspan: 3}= number_with_delimiter @details[:correction] 22 | %tr.info 23 | %th.text-center 總計 24 | %td.text-right{colspan: 3}= number_with_delimiter(@details[:total] + @details[:correction]) 25 | = render "notes", notes: @details[:notes] 26 | -------------------------------------------------------------------------------- /test/decorators/statement_decorator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class StatementDecoratorTest < ActiveSupport::TestCase 5 | def test_declared_income 6 | subject = decorated_subject(amount: 10000, subsidy: 500, correction: 5) 7 | assert_equal 9505, subject.declared_income 8 | assert_equal '9,505', subject.declared_income_cell 9 | end 10 | 11 | def test_paid_amount 12 | subject = decorated_subject(amount: 10000, subsidy: 500, correction: 5) 13 | assert_equal 10005, subject.paid_amount 14 | assert_equal '10,005', subject.paid_amount_cell 15 | end 16 | 17 | private 18 | 19 | def decorated_subject(amount: 0, subsidy: 0, correction: 0) 20 | ActiveDecorator::Decorator.instance.decorate( 21 | build( 22 | :statement, 23 | amount: amount, 24 | subsidy_income: subsidy, 25 | correction: correction, 26 | payroll: build(:payroll) 27 | ) { |statement| create(:correction, statement: statement) } 28 | ) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/services/report_service/service_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class ServiceIncome 4 | include Callable 5 | 6 | attr_reader :year 7 | 8 | def initialize(year) 9 | @year = year.to_i 10 | end 11 | 12 | def call 13 | generate_report 14 | end 15 | 16 | private 17 | 18 | def generate_report 19 | income_data.map do |row| 20 | { 21 | employee: format_employee(row[1][0]), 22 | income: format_as_cells(row[1]), 23 | } 24 | end 25 | end 26 | 27 | def income_data 28 | Report.service_income(year).ordered.group_by(&:employee_id) 29 | end 30 | 31 | def format_employee(data) 32 | { 33 | id: data.employee_id, 34 | name: data.name, 35 | id_number: data.id_number, 36 | address: data.residence_address, 37 | } 38 | end 39 | 40 | def format_as_cells(data) 41 | cells = [] 42 | 12.times { |month| cells << ReportService::ServiceIncomeCell.call(data, year, month) } 43 | cells 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/services/report_service/irregular_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class IrregularIncome 4 | include Callable 5 | 6 | attr_reader :year 7 | 8 | def initialize(year) 9 | @year = year.to_i 10 | end 11 | 12 | def call 13 | generate_report 14 | end 15 | 16 | private 17 | 18 | def generate_report 19 | income_data.sort_by { |row| row[:period] } 20 | end 21 | 22 | def income_data 23 | vacation_refund + overtime + extra_entries 24 | end 25 | 26 | def vacation_refund 27 | Payroll.yearly_vacation_refunds(year).reduce([]) do |rows, payroll| 28 | rows << ReportService::VacationRefundCell.call(payroll) 29 | end 30 | end 31 | 32 | def overtime 33 | Overtime.yearly_report(year).reduce([]) do |rows, overtime| 34 | rows << ReportService::OvertimeCell.call(overtime) 35 | end 36 | end 37 | 38 | def extra_entries 39 | ExtraEntry.yearly_subsidy_report(year).reduce([]) do |rows, entry| 40 | rows << ReportService::ExtraEntryCell.call(entry) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/views/reports/service.haml: -------------------------------------------------------------------------------- 1 | = render "header" 2 | = render "title" 3 | 4 | %p= link_to "匯出 CSV", service_reports_path(params[:year], format: :csv), class: "btn btn-m btn-primary" 5 | 6 | - if @report.any? 7 | %table.table 8 | %tr 9 | %th 姓名 10 | %th.hide 身份證字號 11 | %th.hide 戶籍地址 12 | - 1.upto(12) do |month| 13 | %th= "#{month} 月" 14 | %th 總計 15 | - @report.each do |row| 16 | %tr 17 | %td= link_to row[:employee][:name], employee_path(row[:employee][:id]) 18 | %td.hide= row[:employee][:id_number] 19 | %td.hide= row[:employee][:address] 20 | - 12.times do |month| 21 | = render "cell", cell: row[:income][month] 22 | %td= number_with_delimiter Report.sum_service_income_for(params[:year].to_i, row[:employee][:id]) 23 | %tr 24 | %td 總計 25 | %td.hide 26 | %td.hide 27 | %td= number_with_delimiter Report.sum_by_month(year: (params[:year].to_i - 1), month: 12, tax_code: "9a") 28 | - 1.upto(11) do |month| 29 | %td= number_with_delimiter Report.sum_by_month(year: params[:year], month: month, tax_code: "9a") 30 | %td 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Tsehau Chao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # Install JavaScript dependencies if using Yarn 22 | # system('bin/yarn') 23 | 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:setup' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /test/models/statement_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class StatementTest < ActiveSupport::TestCase 5 | def test_scope_paid 6 | 3.times { prepare_statement(year: 2018, month: 1, amount: 100) } 7 | 2.times { prepare_statement(year: 2018, month: 3, amount: 0) } 8 | 9 | assert_equal 3, Statement.paid.count 10 | end 11 | 12 | def test_scope_by_payroll 13 | 2.times { prepare_statement(year: 2018, month: 1) } 14 | 3.times { prepare_statement(year: 2018, month: 2) } 15 | 16 | assert_equal 0, Statement.by_payroll(2017, 1).count 17 | assert_equal 2, Statement.by_payroll(2018, 1).count 18 | assert_equal 3, Statement.by_payroll(2018, 2).count 19 | end 20 | 21 | def test_scope_ordered 22 | 5.times { prepare_statement(year: 2018, month: 1) } 23 | 24 | assert Statement.ordered.each_cons(2).all? do |first, second| 25 | first.employee_id <= second.employee_id 26 | end 27 | end 28 | 29 | private 30 | 31 | def prepare_statement(year:, month:, amount: 100) 32 | create(:statement, amount: amount, year: year, month: month, payroll: build(:payroll)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/services/calculation_service/total_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module CalculationService 3 | class TotalIncome 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll, :salary 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | @salary = payroll.salary 12 | end 13 | 14 | def call 15 | send(salary.role) 16 | end 17 | 18 | private 19 | 20 | def boss 21 | monthly_wage + extra_income + payroll.festival_bonus 22 | end 23 | 24 | def regular 25 | monthly_wage + equipment_subsidy + supervisor_allowance + commuting_subsidy + 26 | overtime + vacation_refund + extra_income + payroll.festival_bonus 27 | end 28 | 29 | def contractor 30 | monthly_wage + overtime + extra_income + equipment_subsidy + payroll.festival_bonus 31 | end 32 | 33 | def vendor 34 | monthly_wage + extra_income 35 | end 36 | 37 | def parttime 38 | total_wage + salary.commuting_subsidy + extra_income + payroll.festival_bonus 39 | end 40 | 41 | def advisor 42 | total_wage + extra_income 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/views/salaries/recent.js.erb: -------------------------------------------------------------------------------- 1 | $("#salary_monthly_wage").val(<%= @recent_salary.monthly_wage %>) 2 | $("#salary_hourly_wage").val(<%= @recent_salary.hourly_wage %>) 3 | $("#salary_health_insurance").val(<%= @recent_salary.health_insurance %>) 4 | $("#salary_labor_insurance").val(<%= @recent_salary.labor_insurance %>) 5 | $("#salary_insured_for_health").val(<%= @recent_salary.insured_for_health %>) 6 | $("#salary_insured_for_labor").val(<%= @recent_salary.insured_for_labor %>) 7 | $("#salary_fixed_income_tax").val(<%= @recent_salary.fixed_income_tax %>) 8 | $("#salary_equipment_subsidy").val(<%= @recent_salary.equipment_subsidy %>) 9 | $("#salary_commuting_subsidy").val(<%= @recent_salary.commuting_subsidy %>) 10 | $("#salary_supervisor_allowance").val(<%= @recent_salary.supervisor_allowance %>) 11 | $("#salary_monthly_wage_adjustment").val(<%= @recent_salary.monthly_wage_adjustment %>) 12 | $("#salary_role option[value=<%= @recent_salary.role %>]").attr("selected", "selected") 13 | $("#salary_tax_code option[value=<%= @recent_salary.tax_code %>]").attr("selected", "selected") 14 | $("#salary_cycle option[value=<%= @recent_salary.cycle %>]").attr("selected", "selected") 15 | -------------------------------------------------------------------------------- /app/services/calculation_service/total_deduction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module CalculationService 3 | class TotalDeduction 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll, :salary 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | @salary = payroll.salary 12 | end 13 | 14 | def call 15 | send(salary.role) 16 | end 17 | 18 | private 19 | 20 | def basic 21 | labor_insurance + health_insurance + income_tax + payroll.extra_deductions 22 | end 23 | 24 | def boss 25 | basic 26 | end 27 | 28 | def regular 29 | basic + supplement_premium + leavetime + sicktime 30 | end 31 | 32 | def contractor 33 | basic + supplement_premium + leavetime 34 | end 35 | 36 | def vendor 37 | contractor 38 | end 39 | 40 | def parttime 41 | basic + supplement_premium 42 | end 43 | 44 | def advisor 45 | supplement_premium + income_tax + payroll.extra_deductions 46 | end 47 | 48 | # 健保費以足月計算,不做天數調整 49 | def health_insurance 50 | salary.health_insurance 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /config/secrets.yml.sample: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | # Shared secrets are available across all environments. 14 | 15 | # shared: 16 | # api_key: a1B2c3D4e5F6 17 | 18 | # Environmental secrets are only available for that specific environment. 19 | 20 | development: 21 | secret_key_base: 22 | 23 | test: 24 | secret_key_base: 25 | 26 | # Do not keep production secrets in the unencrypted secrets file. 27 | # Instead, either read values from the environment. 28 | # Or, use `bin/rails secrets:setup` to configure encrypted secrets 29 | # and move the `production:` environment over there. 30 | 31 | production: 32 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 33 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # Create Regular 2 | # 3 | 4 | DEFAULT_EMP_AMT = (ENV['DEFAULT_EMP_AMT'] || 10).to_i 5 | MONTH_SALARIES = [50000, 80000, 40000] 6 | 7 | regular_employees = DEFAULT_EMP_AMT.times.map do |i| 8 | FactoryBot.create(:employee_with_payrolls, month_salary: MONTH_SALARIES.sample) 9 | end 10 | 11 | p4r = Payroll.where(employee_id: regular_employees.map(&:id)) 12 | 13 | p4r.select{|p| p.month == 9}.each{|p| p.extra_entries.create title: "中秋禮金", amount: 1500, income_type: "bonus" } 14 | p4r.sample(5).each{|p| p.extra_entries.create title: "誤餐費", amount: 240, income_type: "subsidy" } 15 | p4r.sample(4).each do |p| 16 | d = Date.new(p.year,p.month,1) 17 | ed = d.end_of_month 18 | dd = (d..ed).to_a.sample 19 | hours = (2..6).to_a.sample 20 | rate = dd.on_weekday? ? "weekday" : "weekend" 21 | p.overtimes.create date: dd, hours: hours, rate: rate 22 | end 23 | 24 | contractors = DEFAULT_EMP_AMT.times.map do |i| 25 | FactoryBot.create :employee_with_payrolls, month_salary: MONTH_SALARIES.sample, role: "contractor" 26 | end 27 | 28 | arubaitos = DEFAULT_EMP_AMT.times.map do |i| 29 | FactoryBot.create :parttime_employee_with_payrolls, hour_salary: 200 30 | end 31 | -------------------------------------------------------------------------------- /test/services/calculation_service/leavetime_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module CalculationService 5 | class LeavetimeTest < ActiveSupport::TestCase 6 | def test_leavetime_with_fulltime_salary 7 | salary = build(:salary, monthly_wage: 33000, supervisor_allowance: 3000, cycle: :normal) 8 | payroll = build(:payroll, salary: salary, leavetime_hours: 1) 9 | assert_equal 150, CalculationService::Leavetime.call(payroll) 10 | end 11 | 12 | def test_leavetime_with_parttime_salary 13 | salary = build(:salary, monthly_wage: 24000, equipment_subsidy: 800, monthly_wage_adjustment: 0.6, cycle: :normal) 14 | payroll = build(:payroll, salary: salary, leavetime_hours: 1) 15 | assert_equal 170, CalculationService::Leavetime.call(payroll) 16 | end 17 | 18 | # 15 business days in 2018-02 19 | def test_leavetime_with_business_cycle 20 | salary = build(:salary, monthly_wage: 36000, cycle: :business) 21 | payroll = build(:payroll, salary: salary, leavetime_hours: 1, year: 2018, month: 2) 22 | assert_equal 300, CalculationService::Leavetime.call(payroll) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/controllers/terms_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class TermsController < ApplicationController 3 | before_action :prepare_employee, except: :index 4 | before_action :prepare_term, only: [:show, :edit, :update, :destroy] 5 | 6 | def index 7 | end 8 | 9 | def show 10 | end 11 | 12 | def new 13 | @term = @employee.terms.build 14 | end 15 | 16 | def edit 17 | end 18 | 19 | def create 20 | @term = @employee.terms.new(term_params) 21 | if @term.save 22 | redirect_to @employee 23 | else 24 | render :new 25 | end 26 | end 27 | 28 | def update 29 | if @term.update(term_params) 30 | redirect_to @employee 31 | else 32 | render :edit 33 | end 34 | end 35 | 36 | def destroy 37 | @term.destroy 38 | redirect_to employee_path(@employee) 39 | end 40 | 41 | private 42 | 43 | def term_params 44 | params.require(:term).permit(:start_date, :end_date) 45 | end 46 | 47 | def prepare_employee 48 | (@employee = Employee.find_by(id: params[:employee_id])) || not_found 49 | end 50 | 51 | def prepare_term 52 | (@term = Term.find_by(id: params[:id])) || not_found 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/views/statements/split.haml: -------------------------------------------------------------------------------- 1 | .row 2 | .col-xs-6 3 | %table.table.table-bordered 4 | %tr.active 5 | %th.col-xs-1.text-center 姓名 6 | %th.col-xs-2.text-center= @details[:name] 7 | %th.col-xs-1.text-center 計算月份 8 | %th.col-xs-2.text-center= @details[:period] 9 | %tr.warning 10 | %th.text-center 期數 11 | %th.text-right{colspan: 3} 金額 12 | - @details[:splits].each_with_index do |split, idx| 13 | %tr 14 | %td.text-center ##{idx + 1} 15 | %td.text-right{colspan: 3}= number_with_delimiter split 16 | - unless @details[:correction].zero? 17 | %tr 18 | %th.text-center 系統結算 19 | %td.text-right{colspan: 3}= number_with_delimiter @details[:total] 20 | %tr 21 | %th.text-center 報表校正 22 | %td.text-right{colspan: 3}= number_with_delimiter @details[:correction] 23 | %tr.info 24 | %th.text-center 總計 25 | %td.text-right{colspan: 3}= number_with_delimiter(@details[:total] + @details[:correction]) 26 | %tr 27 | %th.text-center 備註 28 | %td{colspan: 3} 29 | %ul 30 | - @details[:notes].each do |note| 31 | %li= note 32 | -------------------------------------------------------------------------------- /app/services/business_calendar_days_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class BusinessCalendarDaysService 3 | include Callable 4 | 5 | attr_reader :cycle_start, :cycle_end, :year, :month 6 | 7 | def initialize(cycle_start, cycle_end) 8 | @cycle_start = cycle_start 9 | @cycle_end = cycle_end 10 | @year = cycle_start.year 11 | @month = cycle_start.month 12 | end 13 | 14 | def call 15 | days_in_cycle.size - non_working_days 16 | end 17 | 18 | private 19 | 20 | def non_working_days 21 | weekends_in_cycle + dates_in_cycle("holidays") - dates_in_cycle("makeup_days") 22 | end 23 | 24 | def days_in_cycle 25 | (cycle_start..cycle_end).to_a 26 | end 27 | 28 | def weekends_in_cycle 29 | days_in_cycle.select { |day| [0, 6].include?(day.wday) }.size 30 | end 31 | 32 | def dates_in_cycle(type) 33 | days_in_cycle.select { |day| select_dates(type).include? day }.size 34 | end 35 | 36 | def select_dates(type) 37 | return [] unless table[type] 38 | table[type].select { |item| item == month }.values.flatten(2).map { |day| Date.new(year, month, day) } 39 | end 40 | 41 | def table 42 | YAML.load_file("#{Rails.root}/config/holidays/#{year}.yml") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/models/employee.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class Employee < ApplicationRecord 3 | include CollectionTranslatable 4 | 5 | has_many :salaries, dependent: :destroy 6 | accepts_nested_attributes_for :salaries, allow_destroy: true 7 | 8 | has_many :payrolls, dependent: :destroy 9 | has_many :statements, through: :payrolls, source: :statement 10 | has_many :terms, dependent: :destroy 11 | 12 | BANK_TRANSFER_TYPE = { "薪資轉帳": "salary", "台幣轉帳": "normal", "ATM/臨櫃": "atm" }.freeze 13 | 14 | scope :ordered, -> { order(id: :desc) } 15 | 16 | def term(cycle_start, cycle_end) 17 | terms.find_by("start_date <= ? AND (end_date >= ? OR end_date IS NULL)", cycle_end, cycle_start) 18 | end 19 | 20 | def email 21 | return personal_email if resigned? || company_email.blank? 22 | company_email 23 | end 24 | 25 | def recent_term 26 | terms.ordered.first 27 | end 28 | 29 | private 30 | 31 | # 離職當月仍算是在職 32 | def resigned? 33 | return true if current_term.blank? 34 | return false unless current_term.end_date 35 | current_term.end_date < Date.today.beginning_of_month 36 | end 37 | 38 | def current_term 39 | term(Date.today.at_beginning_of_month, Date.today.at_end_of_month) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/services/health_insurance_service/bonus_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 二代健保:同一年內獎金超過投保薪資四倍 3 | module HealthInsuranceService 4 | class BonusIncome 5 | include Callable 6 | include Calculatable 7 | 8 | attr_reader :payroll, :salary 9 | 10 | def initialize(payroll) 11 | @payroll = payroll 12 | @salary = payroll.salary 13 | end 14 | 15 | def call 16 | return 0 unless eligible? 17 | premium 18 | end 19 | 20 | private 21 | 22 | def premium 23 | (premium_base * rate).ceil 24 | end 25 | 26 | def rate 27 | HealthInsuranceService::SupplementPremiumRate.call(payroll.year, payroll.month) 28 | end 29 | 30 | def eligible? 31 | bonus_over_exemption.positive? 32 | end 33 | 34 | def premium_base 35 | [payroll.excess_income, bonus_over_exemption].min 36 | end 37 | 38 | def exemption 39 | salary.insured_for_health * 4 40 | end 41 | 42 | def grand_total 43 | PayrollDetail.yearly_bonus_total_until( 44 | year: payroll.year, 45 | month: payroll.month, 46 | employee_id: payroll.employee_id 47 | ) 48 | end 49 | 50 | def bonus_over_exemption 51 | grand_total - exemption 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/services/health_insurance_service/parttime_income_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module HealthInsuranceService 5 | class ParttimeIncomeTest < ActiveSupport::TestCase 6 | def test_premium_over_exemption 7 | subject = prepare_subject(monthly_wage: 36000, split: false) 8 | assert_equal 688, HealthInsuranceService::ParttimeIncome.call(subject) 9 | end 10 | 11 | def test_premium_under_exemption 12 | subject = prepare_subject(monthly_wage: 22000, split: false) 13 | assert_equal 0, HealthInsuranceService::ParttimeIncome.call(subject) 14 | end 15 | 16 | def test_premium_over_exemption_with_split 17 | subject = prepare_subject(monthly_wage: 36000, split: true) 18 | assert_equal 0, HealthInsuranceService::ParttimeIncome.call(subject) 19 | end 20 | 21 | private 22 | 23 | def prepare_subject(monthly_wage:, split:) 24 | employee = build(:employee) 25 | term = build(:term, start_date: "2018-03-01", employee: employee) 26 | build( 27 | :payroll, 28 | year: 2018, 29 | month: 3, 30 | salary: build(:salary, monthly_wage: monthly_wage, split: split, term: term), 31 | employee: employee 32 | ) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/services/salary_service/sync_payroll_relations_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module SalaryService 5 | class SyncPayrollRelationsTest < ActiveSupport::TestCase 6 | def test_different_salary 7 | employee = build(:employee) 8 | term = build(:term, employee: employee, start_date: "2017-01-01") 9 | old_salary = create(:salary, effective_date: "2017-01-01", employee: employee, term: term) 10 | new_salary = create(:salary, effective_date: "2018-03-01", employee: employee, term: term) 11 | payroll = build(:payroll, year: 2018, month: 3, salary: old_salary, employee: employee) 12 | 13 | SalaryService::SyncPayrollRelations.call(payroll) 14 | 15 | assert_equal new_salary, payroll.salary 16 | end 17 | 18 | def test_same_salary 19 | employee = build(:employee) 20 | term = build(:term, employee: employee, start_date: "2017-01-01") 21 | old_salary = create(:salary, effective_date: "2017-01-01", employee: employee, term: term) 22 | payroll = build(:payroll, year: 2018, month: 3, salary: old_salary, employee: employee) 23 | 24 | SalaryService::SyncPayrollRelations.call(payroll) 25 | 26 | assert_equal old_salary, payroll.salary 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/services/income_tax_service/professional_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class ProfessionalServiceTest < ActiveSupport::TestCase 6 | def test_income_tax_over_exemption 7 | subject = prepare_subject(monthly_wage: 30000, split: false) 8 | assert_equal 3000, IncomeTaxService::ProfessionalService.call(subject) 9 | end 10 | 11 | def test_income_tax_under_exemption 12 | subject = prepare_subject(monthly_wage: 20000, split: false) 13 | assert_equal 0, IncomeTaxService::ProfessionalService.call(subject) 14 | end 15 | 16 | def test_income_tax_over_exemption_with_split 17 | subject = prepare_subject(monthly_wage: 30000, split: true) 18 | assert_equal 0, IncomeTaxService::ProfessionalService.call(subject) 19 | end 20 | 21 | private 22 | 23 | def prepare_subject(monthly_wage:, split:) 24 | employee = build(:employee) 25 | term = build(:term, start_date: "2018-05-01", employee: employee) 26 | build( 27 | :payroll, 28 | year: 2018, 29 | month: 5, 30 | salary: build(:salary, monthly_wage: monthly_wage, split: split, term: term), 31 | employee: employee 32 | ) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/services/health_insurance_service/company_coverage_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module HealthInsuranceService 5 | class CompanyCoverageTest < ActiveSupport::TestCase 6 | def test_premium 7 | prepare_owner(amount: 100000) 8 | prepare_employee(excess: 6000) 9 | prepare_employee(excess: 14000) 10 | prepare_employee(excess: 0) 11 | assert_equal 2292, HealthInsuranceService::CompanyCoverage.call(2016, 1) 12 | end 13 | 14 | private 15 | 16 | def prepare_employee(excess:) 17 | employee = build(:employee) 18 | build( 19 | :payroll, 20 | year: 2016, 21 | month: 1, 22 | salary: build(:salary, tax_code: "50", employee: employee, term: build(:term)), 23 | employee: employee 24 | ) { |payroll| create(:statement, excess_income: excess, payroll: payroll) } 25 | end 26 | 27 | def prepare_owner(amount:) 28 | owner = build(:employee, owner: true) 29 | build( 30 | :payroll, 31 | year: 2016, 32 | month: 1, 33 | salary: build(:salary, employee: owner, term: build(:term)), 34 | employee: owner 35 | ) { |payroll| create(:statement, amount: amount, payroll: payroll) } 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/services/health_insurance_service/professional_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module HealthInsuranceService 5 | class ProfessionalServiceTest < ActiveSupport::TestCase 6 | def test_premium_over_exemption 7 | subject = prepare_subject(monthly_wage: 36000, split: false) 8 | assert_equal 688, HealthInsuranceService::ProfessionalService.call(subject) 9 | end 10 | 11 | def test_premium_under_exemption 12 | subject = prepare_subject(monthly_wage: 20000, split: false) 13 | assert_equal 0, HealthInsuranceService::ProfessionalService.call(subject) 14 | end 15 | 16 | def test_premium_over_exemption_with_split 17 | subject = prepare_subject(monthly_wage: 36000, split: true) 18 | assert_equal 0, HealthInsuranceService::ProfessionalService.call(subject) 19 | end 20 | 21 | private 22 | 23 | def prepare_subject(monthly_wage:, split:) 24 | employee = build(:employee) 25 | term = build(:term, start_date: "2018-01-01", employee: employee) 26 | build( 27 | :payroll, 28 | year: 2018, 29 | month: 1, 30 | salary: build(:salary, monthly_wage: monthly_wage, split: split, term: term), 31 | employee: employee 32 | ) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/services/minimun_wage_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | class MinimumWageServiceTest < ActiveSupport::TestCase 5 | def test_after_202301 6 | assert_equal 26400, MinimumWageService.call(2023, 1) 7 | end 8 | 9 | def test_after_202201 10 | assert_equal 25250, MinimumWageService.call(2022, 1) 11 | end 12 | 13 | def test_after_202101 14 | assert_equal 24000, MinimumWageService.call(2021, 1) 15 | end 16 | 17 | def test_after_202001 18 | assert_equal 23800, MinimumWageService.call(2020, 1) 19 | end 20 | 21 | def test_after_201901 22 | assert_equal 23100, MinimumWageService.call(2019, 1) 23 | end 24 | 25 | def test_after_201801 26 | assert_equal 22000, MinimumWageService.call(2018, 1) 27 | end 28 | 29 | def test_after_201701 30 | assert_equal 21009, MinimumWageService.call(2017, 1) 31 | end 32 | 33 | def test_after_201610 34 | assert_equal 20008, MinimumWageService.call(2016, 10) 35 | end 36 | 37 | def test_after_201407 38 | assert_equal 19273, MinimumWageService.call(2014, 7) 39 | end 40 | 41 | def test_after_201404 42 | assert_equal 19047, MinimumWageService.call(2014, 4) 43 | end 44 | 45 | def test_before_201401 46 | assert_equal 18780, MinimumWageService.call(2013, 12) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/services/statement_service/splitter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module StatementService 3 | class Splitter 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll, :salary, :array 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | @salary = payroll.salary 12 | @array = [] 13 | end 14 | 15 | def call 16 | split_statement? ? splits : nil 17 | end 18 | 19 | private 20 | 21 | def split_statement? 22 | return false unless salary.split? 23 | paid_amount > split_base 24 | end 25 | 26 | def split_base 27 | if salary.parttime_income_uninsured_for_labor? 28 | minimum_wage 29 | else 30 | professional_service_threshold 31 | end 32 | end 33 | 34 | def splits 35 | ratio.times { array << split_interval } 36 | array << (paid_amount - (ratio * split_interval)) 37 | array.delete 0 38 | array 39 | end 40 | 41 | def ratio 42 | paid_amount / split_interval 43 | end 44 | 45 | def minimum_wage 46 | MinimumWageService.call(payroll.year, payroll.month) 47 | end 48 | 49 | def professional_service_threshold 50 | 20000 51 | end 52 | 53 | # 用低於最低薪資的整數拆單 54 | def split_interval 55 | 20000 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/services/income_tax_service/uninsuranced_salary_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class UninsurancedSalaryTest < ActiveSupport::TestCase 6 | def test_income_tax_over_exemption 7 | subject = prepare_subject(monthly_wage: 100000, festival_bonus: 100000, split: false) 8 | assert_equal 10000, IncomeTaxService::UninsurancedSalary.call(subject) 9 | end 10 | 11 | def test_income_tax_under_exemption 12 | subject = prepare_subject(monthly_wage: 50000, festival_bonus: 50000, split: false) 13 | assert_equal 0, IncomeTaxService::UninsurancedSalary.call(subject) 14 | end 15 | 16 | def test_income_tax_over_exemption_with_split 17 | subject = prepare_subject(monthly_wage: 100000, split: true) 18 | assert_equal 0, IncomeTaxService::UninsurancedSalary.call(subject) 19 | end 20 | 21 | private 22 | 23 | def prepare_subject(monthly_wage:, festival_bonus: 0, split:) 24 | employee = build(:employee) 25 | term = build(:term, start_date: "2018-01-01", employee: employee) 26 | build( 27 | :payroll, 28 | year: 2018, 29 | month: 1, 30 | festival_bonus: festival_bonus, 31 | salary: build(:salary, monthly_wage: monthly_wage, split: split, term: term), 32 | employee: employee 33 | ) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :employees do 3 | get "inactive", on: :collection 4 | get "parttimers", on: :collection 5 | 6 | resources :salaries 7 | get "salaries/recent/:employee_id", to: "salaries#recent", as: :salaries_recent 8 | 9 | resources :terms 10 | end 11 | 12 | resources :payrolls, except: [:new, :create, :show] 13 | get "payrolls/init_regulars/:year/:month", to: "payrolls#init_regulars", as: :init_regulars, constraints: { year: /\d{4}/, month: /\d{1,2}/ } 14 | get "payrolls/parttimers/:year/:month", to: "payrolls#parttimers", as: :parttimers, constraints: { year: /\d{4}/, month: /\d{1,2}/ } 15 | post "payrolls/parttimers/:year/:month", to: "payrolls#init_parttimers", constraints: { year: /\d{4}/, month: /\d{1,2}/ } 16 | 17 | resources :statements, only: [:index, :show, :edit, :update] 18 | 19 | resources :reports, only: [:index] do 20 | collection do 21 | get "salary/:year", to: "reports#salary", as: :salary, constraints: { year: /\d{4}/ } 22 | get "service/:year", to: "reports#service", as: :service, constraints: { year: /\d{4}/ } 23 | get "irregular/:year", to: "reports#irregular", as: :irregular, constraints: { year: /\d{4}/ } 24 | get "monthly/:year/:month", to: "reports#monthly", as: :monthly, constraints: { year: /\d{4}/, month: /\d{1,2}/ } 25 | end 26 | end 27 | 28 | root to: "employees#index" 29 | end 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) do |repo_name| 4 | repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/") 5 | "https://github.com/#{repo_name}.git" 6 | end 7 | 8 | gem 'rails', '~> 6.1.7.4' 9 | gem 'pg' 10 | gem 'puma', '>= 6.3.1' 11 | gem 'sass-rails' 12 | gem 'uglifier' 13 | gem 'bootsnap' 14 | 15 | gem 'coffee-rails' 16 | gem 'turbolinks' 17 | gem 'jbuilder' 18 | gem 'haml' 19 | gem 'bootstrap-sass' 20 | gem 'simple_form' 21 | gem 'active_decorator' 22 | gem 'ransack' 23 | gem 'kaminari' 24 | gem 'figaro' 25 | gem 'scenic' 26 | 27 | gem 'jquery-rails' 28 | gem 'cocoon' 29 | 30 | gem 'wicked_pdf' 31 | gem 'wkhtmltopdf-binary', '0.12.3.1' # 0.12.4.0 shrinks text 32 | gem 'pdf-toolkit' 33 | 34 | gem 'rack' 35 | gem 'nokogiri' 36 | 37 | group :development, :test do 38 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 39 | gem 'capybara', '~> 2.13' 40 | gem 'selenium-webdriver' 41 | gem 'taiwanese_id_validator' 42 | gem 'factory_bot_rails', '~> 4.0' 43 | gem 'faker' 44 | gem 'rubocop-rails' 45 | gem 'time_difference' 46 | gem 'timecop' 47 | gem 'mocha' 48 | end 49 | 50 | group :development do 51 | gem 'web-console', '>= 3.3.0' 52 | gem 'listen', '>= 3.0.5', '< 3.2' 53 | gem 'spring' 54 | gem 'spring-watcher-listen', '~> 2.0.0' 55 | gem 'bullet' 56 | gem 'rb-readline' 57 | gem 'irb' 58 | end 59 | 60 | group :test do 61 | gem 'database_rewinder' 62 | gem 'simplecov', require: false 63 | end 64 | -------------------------------------------------------------------------------- /app/services/report_service/monthly_statements.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class MonthlyStatements 4 | include Callable 5 | 6 | attr_reader :year, :month 7 | 8 | def initialize(year, month) 9 | @year = year.to_i 10 | @month = month.to_i 11 | end 12 | 13 | def call 14 | generate_report 15 | end 16 | 17 | private 18 | 19 | def income_data 20 | @income_data ||= Report.salary_income_by_month(year, month).ordered.group_by(&:employee_id) 21 | end 22 | 23 | def generate_report 24 | { 25 | data: income_data.map { |row| format_row(row[1][0]) }, 26 | gain_keys: base_keys(:gain), 27 | loss_keys: base_keys(:loss), 28 | gain_sum: sum(:gain), 29 | loss_sum: sum(:loss), 30 | } 31 | end 32 | 33 | def format_row(data) 34 | { 35 | name: data.name, 36 | gain: column_base(:gain).merge(data.gain), 37 | loss: column_base(:loss).merge(data.loss), 38 | } 39 | end 40 | 41 | def column_base(column) 42 | base_keys(column).index_with { |key| 0 } 43 | end 44 | 45 | def base_keys(column) 46 | income_data.inject([]) do |memo, row| 47 | memo += row[1][0].send(column).keys 48 | memo 49 | end.uniq 50 | end 51 | 52 | def sum(column) 53 | Report.column_by_month(year, month, column).inject { |a, b| a.merge(b) { |_, x, y| x + y } } 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/services/income_tax_service/dispatcher_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module IncomeTaxService 5 | class DispatcherTest < ActiveSupport::TestCase 6 | def test_bypass_when_business 7 | subject = prepare_subject(tax_code: nil, insured: 0, b2b: true) 8 | assert_equal 0, IncomeTaxService::Dispatcher.call(subject) 9 | end 10 | 11 | def test_dispatches_to_professional_service 12 | subject = prepare_subject(tax_code: "9a", insured: 0, b2b: false) 13 | IncomeTaxService::ProfessionalService.expects(:call).returns(true) 14 | assert IncomeTaxService::Dispatcher.call(subject) 15 | end 16 | 17 | def test_dispatches_to_uninsuranced_salary 18 | subject = prepare_subject(tax_code: 50, insured: 0, b2b: false) 19 | IncomeTaxService::UninsurancedSalary.expects(:call).returns(true) 20 | assert IncomeTaxService::Dispatcher.call(subject) 21 | end 22 | 23 | def test_dispatches_to_regular_employee 24 | subject = prepare_subject(tax_code: 50, insured: 11100, b2b: false) 25 | IncomeTaxService::RegularEmployee.expects(:call).returns(true) 26 | assert IncomeTaxService::Dispatcher.call(subject) 27 | end 28 | 29 | private 30 | 31 | def prepare_subject(tax_code:, insured:, b2b:) 32 | build( 33 | :payroll, 34 | salary: build(:salary, tax_code: tax_code, insured_for_labor: insured), 35 | employee: build(:employee, b2b: b2b) 36 | ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/services/health_insurance_service/dispatcher_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module HealthInsuranceService 5 | class DispatcherTest < ActiveSupport::TestCase 6 | def test_bypass_when_business 7 | subject = prepare_subject(tax_code: nil, insured: 0, b2b: true) 8 | assert_equal 0, HealthInsuranceService::Dispatcher.call(subject) 9 | end 10 | 11 | def test_dispatches_to_professional_service 12 | subject = prepare_subject(tax_code: "9a", insured: 0, b2b: false) 13 | HealthInsuranceService::ProfessionalService.expects(:call).returns(true) 14 | assert HealthInsuranceService::Dispatcher.call(subject) 15 | end 16 | 17 | def test_dispatches_to_parttime_income 18 | subject = prepare_subject(tax_code: 50, insured: 0, b2b: false) 19 | HealthInsuranceService::ParttimeIncome.expects(:call).returns(true) 20 | assert HealthInsuranceService::Dispatcher.call(subject) 21 | end 22 | 23 | def test_dispatches_to_bonus_income 24 | subject = prepare_subject(tax_code: 50, insured: 22000, b2b: false) 25 | HealthInsuranceService::BonusIncome.expects(:call).returns(true) 26 | assert HealthInsuranceService::Dispatcher.call(subject) 27 | end 28 | 29 | private 30 | 31 | def prepare_subject(tax_code:, insured:, b2b:) 32 | build( 33 | :payroll, 34 | salary: build(:salary, tax_code: tax_code, insured_for_health: insured), 35 | employee: build(:employee, b2b: b2b) 36 | ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/controllers/statements_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class StatementsController < ApplicationController 3 | before_action :prepare_statement, only: [:show, :edit, :update] 4 | before_action :prepare_details, only: [:show, :edit] 5 | 6 | def index 7 | @query = Statement.paid.ordered.ransack(params[:query]) 8 | @statements = @query.result(distinct: true).page(params[:page]) 9 | end 10 | 11 | def show 12 | render_statement 13 | end 14 | 15 | def edit 16 | end 17 | 18 | def update 19 | if StatementService::Update.call(@statement, statement_params) 20 | redirect_to session.delete(:return_to) 21 | else 22 | render :edit 23 | end 24 | end 25 | 26 | private 27 | 28 | def prepare_statement 29 | (@statement = Statement.find_by(id: params[:id])) || not_found 30 | end 31 | 32 | def prepare_details 33 | @details = FormatService::StatementDetails.call(@statement) 34 | end 35 | 36 | def statement_params 37 | params.require(:statement).permit( 38 | corrections_attributes: [:description, :amount, :_destroy, :id] 39 | ) 40 | end 41 | 42 | def render_statement 43 | respond_to do |format| 44 | format.html { render @details[:template] } 45 | format.pdf { render_statement_pdf } 46 | end 47 | end 48 | 49 | def render_statement_pdf 50 | render( 51 | template: @details[:template], 52 | pdf: @details[:filename], 53 | encoding: "utf-8", 54 | layout: "pdf", 55 | orientation: "landscape" 56 | ) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/views/reports/salary.haml: -------------------------------------------------------------------------------- 1 | = render "header" 2 | = render "title" 3 | 4 | %p= link_to "匯出 CSV", salary_reports_path(params[:year], format: :csv), class: "btn btn-m btn-primary" 5 | 6 | - if @report.any? 7 | %table.table 8 | %tr 9 | %th 姓名 10 | %th.hide 身份證字號 11 | %th.hide 戶籍地址 12 | - 1.upto(12) do |month| 13 | %th= link_to "#{month} 月", monthly_reports_path(params[:year], month) 14 | %th 端午 15 | %th 中秋 16 | %th 年終 17 | %th 總計 18 | 19 | - @report.each do |row| 20 | %tr 21 | %td= link_to row[:employee][:name], employee_path(row[:employee][:id]) 22 | %td.hide= row[:employee][:id_number] 23 | %td.hide= row[:employee][:address] 24 | - 12.times do |month| 25 | = render "cell", cell: row[:income][month] 26 | %td= number_with_delimiter row[:festival_bonus][:dragonboat] 27 | %td= number_with_delimiter row[:festival_bonus][:midautumn] 28 | %td= number_with_delimiter row[:festival_bonus][:newyear] 29 | %td= number_with_delimiter Report.sum_salary_income_for(params[:year], row[:employee][:id]) 30 | 31 | %tr 32 | %td 總計 33 | %td.hide 34 | %td.hide 35 | - 1.upto(12) do |month| 36 | %td= number_with_delimiter Report.sum_by_month(year: params[:year], month: month, tax_code: 50) 37 | %td= number_with_delimiter Report.sum_by_festival(params[:year], :dragonboat) 38 | %td= number_with_delimiter Report.sum_by_festival(params[:year], :midautumn) 39 | %td= number_with_delimiter Report.sum_by_festival(params[:year], :newyear) 40 | %td 41 | -------------------------------------------------------------------------------- /app/views/salaries/_form.haml: -------------------------------------------------------------------------------- 1 | %h3 薪資設定 2 | = link_to "帶入最近薪資資料", employee_salaries_recent_path(employee), class: "btn btn-xs btn-default", remote: true 3 | = f.input_field :term_id, as: :hidden, value: term.id 4 | %table.table 5 | %tr 6 | %th.col-xs-1= f.label "起薪日期" 7 | %td.col-xs-2= f.input_field :effective_date, type: :date 8 | %th.col-xs-1= f.label "身份" 9 | %td.col-xs-2= f.input_field :role, collection: Salary::ROLE 10 | %tr 11 | %th= f.label "月薪" 12 | %td= f.input_field :monthly_wage 13 | %th= f.label "所得稅別" 14 | %td 15 | = f.input_field :tax_code, collection: Salary::TAX_CODE 16 | = f.input_field :split 17 | 拆單 18 | %tr 19 | %th= f.label "時薪" 20 | %td= f.input_field :hourly_wage 21 | %th= f.label "交通津貼" 22 | %td= f.input_field :commuting_subsidy 23 | %tr 24 | %th= f.label "設備津貼" 25 | %td= f.input_field :equipment_subsidy 26 | %th= f.label "主管加給" 27 | %td= f.input_field :supervisor_allowance 28 | %tr 29 | %th= f.label "勞保投保金額" 30 | %td= f.input_field :insured_for_labor 31 | %th= f.label "勞保費" 32 | %td= f.input_field :labor_insurance 33 | %tr 34 | %th= f.label "健保投保金額" 35 | %td= f.input_field :insured_for_health 36 | %th= f.label "健保費" 37 | %td= f.input_field :health_insurance 38 | %tr 39 | %th= f.label "所得稅" 40 | %td= f.input_field :fixed_income_tax 41 | %th 42 | %td 43 | %tr 44 | %th= f.label "計算周期" 45 | %td= f.input_field :cycle, collection: Salary::CYCLE 46 | %th= f.label "兼職調整", title: "每週天數 / 5", class: "tip" 47 | %td= f.input_field :monthly_wage_adjustment 48 | -------------------------------------------------------------------------------- /lib/tasks/import.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "csv" 3 | 4 | namespace :import do 5 | desc "import csv" 6 | task csv: :environment do 7 | CSV.foreach(ENV.fetch("file"), headers: true, header_converters: :symbol) do |row| 8 | data = row.to_hash 9 | employee = { 10 | name: data[:name], 11 | id_number: data[:id_number], 12 | residence_address: data[:residence_address], 13 | birthday: data[:birthday], 14 | company_email: data[:company_email], 15 | personal_email: data[:personal_email], 16 | bank_account: data[:bank_account], 17 | bank_transfer_type: data[:bank_transfer_type], 18 | } 19 | salary = { 20 | role: data[:role], 21 | tax_code: data[:tax_code], 22 | effective_date: data[:effective_date], 23 | monthly_wage: data[:monthly_wage], 24 | hourly_wage: data[:hourly_wage], 25 | equipment_subsidy: data[:equipment_subsidy], 26 | commuting_subsidy: data[:commuting_subsidy], 27 | supervisor_allowance: data[:supervisor_allowance], 28 | health_insurance: data[:health_insurance], 29 | labor_insurance: data[:labor_insurance], 30 | insured_for_health: data[:insured_for_health], 31 | insured_for_labor: data[:insured_for_labor], 32 | cycle: data[:cycle], 33 | } 34 | term = { 35 | start_date: data[:start_date], 36 | end_date: data[:end_date], 37 | } 38 | Employee.create(employee) do |e| 39 | e.salaries.new(salary) 40 | e.terms.new(term) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/services/report_service/salary_income.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ReportService 3 | class SalaryIncome 4 | include Callable 5 | 6 | attr_reader :year 7 | 8 | def initialize(year) 9 | @year = year.to_i 10 | end 11 | 12 | def call 13 | generate_report 14 | end 15 | 16 | private 17 | 18 | def generate_report 19 | income_data.map { |row| format_row(row[1]) } 20 | end 21 | 22 | def income_data 23 | Report.salary_income(year).ordered.group_by(&:employee_id) 24 | end 25 | 26 | def format_row(data) 27 | { 28 | employee: format_employee(data.first), 29 | income: format_as_cells(data), 30 | festival_bonus: format_festival_bonus(data), 31 | } 32 | end 33 | 34 | def format_employee(data) 35 | { 36 | id: data.employee_id, 37 | name: data.name, 38 | id_number: data.id_number, 39 | address: data.residence_address, 40 | } 41 | end 42 | 43 | def format_as_cells(data) 44 | cells = [] 45 | 1.upto(12) { |month| cells << data.select { |cell| cell.month == month }.first } 46 | cells 47 | end 48 | 49 | def format_festival_bonus(data) 50 | hash = {} 51 | Payroll::FESTIVAL_BONUS.values.map do |festival_type| 52 | hash[festival_type.to_sym] = festival_bonus(data, festival_type) 53 | end 54 | hash 55 | end 56 | 57 | def festival_bonus(data, festival_type) 58 | match = data.select { |cell| cell.festival_type == festival_type }.first 59 | match ? match.festival_bonus : 0 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/services/statement_service/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module StatementService 3 | class Builder 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :statement, :payroll, :salary 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | @salary = payroll.salary 12 | @statement = Statement.find_or_create_by(payroll_id: payroll.id) 13 | end 14 | 15 | def call 16 | statement.update(params) 17 | end 18 | 19 | private 20 | 21 | def params 22 | split_statement? ? split_params : unsplit_params 23 | end 24 | 25 | def split_statement? 26 | return false if salary.regular_income? 27 | splits.present? 28 | end 29 | 30 | def splits 31 | @splits ||= StatementService::Splitter.call(payroll) 32 | end 33 | 34 | def unsplit_params 35 | { 36 | amount: total_income - total_deduction, 37 | year: payroll.year, 38 | month: payroll.month, 39 | splits: nil, 40 | subsidy_income: subsidy_income, 41 | excess_income: excess_income, 42 | gain: FormatService::Income.call(payroll), 43 | loss: FormatService::Deductions.call(payroll), 44 | } 45 | end 46 | 47 | def split_params 48 | { 49 | amount: splits.reduce(:+), 50 | year: payroll.year, 51 | month: payroll.month, 52 | splits: splits, 53 | subsidy_income: subsidy_income, 54 | excess_income: 0, 55 | # TODO: gain, loss 要保留 payroll 的原始項目 56 | gain: FormatService::Income.call(payroll), 57 | loss: FormatService::Deductions.call(payroll), 58 | } 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /app/services/format_service/statement_notes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FormatService 3 | class StatementNotes 4 | include Callable 5 | include PayrollPeriodCountable 6 | 7 | attr_reader :payroll, :salary, :notes 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | @salary = payroll.salary 12 | @notes = [] 13 | end 14 | 15 | def call 16 | generate_notes 17 | notes 18 | end 19 | 20 | private 21 | 22 | def generate_notes 23 | employment_date_notes 24 | income_notes 25 | deduction_notes 26 | extra_notes 27 | end 28 | 29 | def income_notes 30 | hourly_based_note(payroll.parttime_hours, "工作時數") 31 | hourly_based_note(payroll.vacation_refund_hours, "特休折現") 32 | overtime_notes 33 | end 34 | 35 | def deduction_notes 36 | hourly_based_note(payroll.leavetime_hours, "扣薪事假") 37 | hourly_based_note(payroll.sicktime_hours, "扣薪病假") 38 | end 39 | 40 | def hourly_based_note(hours, title) 41 | notes << "#{title} #{hours} 小時" if hours.positive? 42 | end 43 | 44 | def employment_date_notes 45 | return if salary.partner? 46 | notes << "#{salary.term.start_date} 到職" if first_month? 47 | notes << "#{salary.term.end_date} 離職" if final_month? 48 | end 49 | 50 | def overtime_notes 51 | payroll.overtimes.map do |overtime| 52 | notes << "#{overtime.date.strftime('%Y-%m-%d')} 加班 #{overtime.hours} 小時" 53 | end 54 | end 55 | 56 | def extra_notes 57 | payroll.extra_entries.map do |extra_entry| 58 | notes << extra_entry.note if extra_entry.note? 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /.github/workflows/rails.yml: -------------------------------------------------------------------------------- 1 | name: Rails 2 | on: push 3 | 4 | jobs: 5 | verify: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | postgres: 11 | image: postgres:11@sha256:85d79cba2d4942dad7c99f84ec389a5b9cc84fb07a3dcd3aff0fb06948cdc03b 12 | env: 13 | PG_USER: postgres 14 | PG_DATABASE: rails_github_actions_test 15 | PG_PASSWORD: postgres 16 | ports: ["5432:5432"] 17 | options: >- 18 | --health-cmd pg_isready 19 | --health-interval 10s 20 | --health-timeout 5s 21 | --health-retries 5 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: ruby/setup-ruby@v1 26 | with: 27 | bundler-cache: true 28 | - name: Install dependencies 29 | run: | 30 | sudo apt-get -yqq install libpq-dev build-essential libcurl4-openssl-dev 31 | gem install bundler:2.3.6 32 | bundle lock --add-platform x86_64-linux 33 | bundle install --jobs 4 --retry 3 34 | - name: Setup test database 35 | env: 36 | RAILS_ENV: test 37 | PG_HOST: localhost 38 | PG_DATABASE: rails_github_actions_test 39 | PG_USER: postgres 40 | PG_PASSWORD: postgres 41 | run: | 42 | cp config/database.yml.sample config/database.yml 43 | bundle exec rake db:create db:migrate 44 | - name: Run tests 45 | env: 46 | PG_HOST: localhost 47 | PG_DATABASE: rails_github_actions_test 48 | PG_USER: postgres 49 | PG_PASSWORD: postgres 50 | PG_PORT: ${{ job.services.postgres.ports[5432] }} 51 | RAILS_ENV: test 52 | run: bundle exec rake 53 | -------------------------------------------------------------------------------- /app/controllers/salaries_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class SalariesController < ApplicationController 3 | before_action :prepare_employee, except: :index 4 | before_action :prepare_salary, only: [:show, :edit, :update, :destroy] 5 | 6 | def index 7 | end 8 | 9 | def show 10 | end 11 | 12 | def new 13 | (@term = Term.find_by(id: params[:term_id])) || not_found 14 | @salary = @employee.salaries.build 15 | end 16 | 17 | def edit 18 | end 19 | 20 | def create 21 | @salary = @employee.salaries.new(salary_params) 22 | if SalaryService::Create.call(@salary, salary_params) 23 | redirect_to @employee 24 | else 25 | render :new 26 | end 27 | end 28 | 29 | def update 30 | if SalaryService::Update.call(@salary, salary_params) 31 | redirect_to @employee 32 | else 33 | render :edit 34 | end 35 | end 36 | 37 | def destroy 38 | SalaryService::Destroy.call(@salary, nil) 39 | redirect_to employee_path(@employee) 40 | end 41 | 42 | def recent 43 | @recent_salary = Salary.recent_for(@employee) 44 | end 45 | 46 | private 47 | 48 | def salary_params 49 | params.require(:salary).permit( 50 | :role, :tax_code, :effective_date, :monthly_wage, :hourly_wage, :cycle, 51 | :equipment_subsidy, :commuting_subsidy, :supervisor_allowance, 52 | :labor_insurance, :health_insurance, :insured_for_labor, :insured_for_health, 53 | :monthly_wage_adjustment, :fixed_income_tax, :split, :term_id 54 | ) 55 | end 56 | 57 | def prepare_employee 58 | (@employee = Employee.find_by(id: params[:employee_id])) || not_found 59 | end 60 | 61 | def prepare_salary 62 | (@salary = Salary.find_by(id: params[:id])) || not_found 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/models/salary_tracker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class SalaryTracker < ApplicationRecord 3 | include CollectionTranslatable 4 | 5 | ROLE = Salary::ROLE 6 | 7 | class << self 8 | def on_payroll(year: Date.today.year, month: Date.today.month) 9 | select("DISTINCT ON (employee_id) *") 10 | .where("term_start <= ?", Date.new(year, month, -1)) 11 | .where("(term_end >= ? OR term_end IS NULL)", Date.new(year, month, 1)) 12 | .where("salary_start <= ?", Date.new(year, month, -1)) 13 | .order(employee_id: :desc, salary_start: :desc) 14 | end 15 | 16 | # 主要和 #on_payroll 組合使用。直接下 query 的話會抓到不該有的 edge case 所以另外篩選 17 | def by_role(role:) 18 | if role.is_a? Array 19 | select { |row| role.include? row.role } 20 | else 21 | select { |row| row.role == role } 22 | end 23 | end 24 | 25 | def inactive(year: Date.today.year, month: Date.today.month) 26 | select("DISTINCT ON (employee_id) *") 27 | .where.not(employee_id: active_terms) 28 | .where("term_end <= ?", Date.new(year, month, -1)) 29 | .order(employee_id: :desc) 30 | end 31 | 32 | def salary_by_payroll(payroll:) 33 | where(employee_id: payroll.employee_id) 34 | .where("term_start <= ?", Date.new(payroll.year, payroll.month, -1)) 35 | .where("salary_start <= ?", Date.new(payroll.year, payroll.month, -1)) 36 | .order(salary_start: :desc) 37 | .pick(:salary_id) 38 | end 39 | 40 | def term_by_salary(salary_id:) 41 | select(:term_start, :term_end).find_by(salary_id: salary_id) 42 | end 43 | 44 | private 45 | 46 | def active_terms 47 | select(:employee_id).distinct.where(term_end: nil) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/tasks/dev.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | namespace :dev do 3 | desc "reset db" 4 | task reset_database: :environment do 5 | raise "You cannot run this in production" if Rails.env.production? 6 | Rake::Task["db:drop"].execute 7 | Rake::Task["db:create"].execute 8 | Rake::Task["db:migrate"].execute 9 | end 10 | 11 | desc "force update salary/payroll/statement data" 12 | task fix_data: :environment do 13 | Payroll.ordered.map do |payroll| 14 | salary_id = SalaryTracker.salary_by_payroll(payroll: payroll) 15 | payroll.update(salary_id: salary_id) 16 | StatementService::Builder.call(payroll) 17 | end 18 | end 19 | 20 | # 做第一次資料移轉 21 | desc "move employee start/end date to terms" 22 | task init_term: :environment do 23 | Employee.all.map do |employee| 24 | Term.create(start_date: employee.start_date, end_date: employee.end_date, employee_id: employee.id) 25 | end 26 | end 27 | 28 | desc "statement force update" 29 | task update_statements: :environment do 30 | Payroll.ordered.map do |payroll| 31 | StatementService::Builder.call(payroll) 32 | end 33 | end 34 | 35 | # 修補 salary/term 的關連資料 36 | desc "fix salary/term relationship" 37 | task salary_term: :environment do 38 | Salary.all.map do |salary| 39 | term = SalaryService::TermFinder.call(salary) 40 | salary.term_id = term.id 41 | salary.save 42 | end 43 | end 44 | 45 | # 計算 correction 46 | desc "update correction" 47 | task correction: :environment do 48 | Statement.all.map do |statement| 49 | if statement.corrections.any? 50 | statement.correction = statement.corrections.inject(0) { |sum, row| sum + row.amount } 51 | statement.save 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/controllers/reports_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "csv" 3 | class ReportsController < ApplicationController 4 | before_action :store_location, except: [:index] 5 | 6 | def index 7 | return unless report_type && year 8 | redirect_to action: report_type, year: year 9 | end 10 | 11 | def salary 12 | @report = ReportService::SalaryIncome.call(year) 13 | render_report 14 | end 15 | 16 | def service 17 | @report = ReportService::ServiceIncome.call(year) 18 | render_report 19 | end 20 | 21 | def irregular 22 | @report = ReportService::IrregularIncome.call(year) 23 | render_report 24 | end 25 | 26 | def monthly 27 | @report = ReportService::MonthlyStatements.call(year, month) 28 | render_report 29 | end 30 | 31 | private 32 | 33 | def report_type 34 | params[:report_type] 35 | end 36 | 37 | def year 38 | params[:year] 39 | end 40 | 41 | def month 42 | params[:month] 43 | end 44 | 45 | def render_report 46 | respond_to do |format| 47 | format.html 48 | format.csv { send_data(generate_csv_data, filename: filename) } 49 | end 50 | end 51 | 52 | def generate_csv_data 53 | data_table.css("tr").map do |row| 54 | row.css("td, th").map(&:text).to_csv 55 | end.join.encode("utf-8", undef: :replace, replace: "") 56 | end 57 | 58 | def data_table 59 | template ||= "reports/#{action_name}.haml" 60 | content = render_to_string(template) 61 | Nokogiri::HTML(content).at_css("table") 62 | end 63 | 64 | def filename 65 | if month 66 | "#{params[:year]}_#{sprintf('%02d', params[:month])}_#{t(action_name, scope: :reports)}.csv" 67 | else 68 | "#{params[:year]}_#{t(action_name, scope: :reports)}.csv" 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /app/models/salary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Salary 與 Payroll 為間接關聯,更新需要透過 SalaryService 同步 3 | class Salary < ApplicationRecord 4 | include CollectionTranslatable 5 | 6 | belongs_to :employee 7 | belongs_to :term 8 | 9 | ROLE = { 10 | "老闆": "boss", 11 | "正職": "regular", 12 | "約聘": "contractor", 13 | "外包": "vendor", 14 | "時薪兼職": "parttime", 15 | "顧問/講師": "advisor", 16 | }.freeze 17 | 18 | TAX_CODE = { "薪資": "50", "執行專業所得": "9a" }.freeze 19 | CYCLE = { "一般": "normal", "工作天": "business" }.freeze 20 | 21 | scope :ordered, -> { order(effective_date: :desc).includes(:term) } 22 | scope :recent_for, ->(employee) { where(employee_id: employee.id).ordered.take } 23 | scope :effective_by, ->(cycle_end) { where("effective_date < ?", cycle_end).ordered.take } 24 | 25 | def income_with_subsidies 26 | (monthly_wage / monthly_wage_adjustment) + supervisor_allowance + equipment_subsidy + commuting_subsidy 27 | end 28 | 29 | # 一般薪資所得 30 | def regular_income? 31 | (tax_code == "50") && insured_for_labor.positive? && insured_for_health.positive? 32 | end 33 | 34 | # 兼職薪資所得(所得稅用) 35 | def parttime_income_uninsured_for_labor? 36 | (tax_code == "50") && insured_for_labor.zero? 37 | end 38 | 39 | # 兼職薪資所得(二代健保用) 40 | def parttime_income_uninsured_for_health? 41 | (tax_code == "50") && insured_for_health.zero? 42 | end 43 | 44 | # 執行業務所得 45 | def professional_service? 46 | tax_code == "9a" 47 | end 48 | 49 | def business_calendar? 50 | cycle == "business" 51 | end 52 | 53 | # 無僱傭關係的合作對象 54 | def partner? 55 | %w(vendor advisor).include? role 56 | end 57 | 58 | def insured_for_labor_and_uninsured_for_health? 59 | (tax_code == "50") && insured_for_health.zero? && insured_for_labor.positive? 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/services/statement_service/splitter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module StatementService 5 | class SplitterTest < ActiveSupport::TestCase 6 | def test_no_splits_for_regular_income 7 | subject = prepare_subject(tax_code: 50, insured: 45800, wage: 40000) 8 | assert_nil StatementService::Splitter.call(subject) 9 | end 10 | 11 | def test_split_for_professional_service 12 | subject = prepare_subject(tax_code: "9a", insured: 0, wage: 50000, split: true) 13 | assert_equal [20000, 20000, 10000], StatementService::Splitter.call(subject) 14 | 15 | subject = prepare_subject(tax_code: "9a", insured: 0, wage: 20000, split: false) 16 | assert_nil StatementService::Splitter.call(subject) 17 | end 18 | 19 | def test_split_for_parttime_salary 20 | subject = prepare_subject(tax_code: 50, insured: 0, wage: 50000, split: true) 21 | assert_equal [20000, 20000, 10000], StatementService::Splitter.call(subject) 22 | 23 | subject = prepare_subject(tax_code: 50, insured: 0, wage: 22000, split: false) 24 | assert_nil StatementService::Splitter.call(subject) 25 | end 26 | 27 | private 28 | 29 | def prepare_subject(tax_code: 50, insured: 0, wage:, split: false) 30 | employee = build(:employee) 31 | term = build(:term, start_date: "2018-01-01", employee: employee) 32 | build( 33 | :payroll, 34 | year: 2018, 35 | month: 1, 36 | salary: build( 37 | :salary, 38 | monthly_wage: wage, 39 | tax_code: tax_code, 40 | insured_for_health: insured, 41 | insured_for_labor: insured, 42 | split: split, 43 | term: term 44 | ), 45 | employee: employee 46 | ) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/services/format_service/deductions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module FormatService 3 | class Deductions 4 | include Callable 5 | include Calculatable 6 | 7 | attr_reader :payroll, :salary 8 | 9 | def initialize(payroll) 10 | @payroll = payroll 11 | @salary = payroll.salary 12 | end 13 | 14 | def call 15 | cleanup send(salary.role) 16 | end 17 | 18 | private 19 | 20 | def boss 21 | { 22 | 勞保費: labor_insurance, 23 | 健保費: health_insurance, 24 | 所得稅: income_tax, 25 | }.merge(extra_loss) 26 | end 27 | 28 | def regular 29 | { 30 | 勞保費: labor_insurance, 31 | 健保費: health_insurance, 32 | 二代健保: supplement_premium, 33 | 所得稅: income_tax, 34 | 請假扣薪: leavetime + sicktime, 35 | }.merge(extra_loss) 36 | end 37 | 38 | def contractor 39 | { 40 | 勞保費: labor_insurance, 41 | 健保費: health_insurance, 42 | 二代健保: supplement_premium, 43 | 所得稅: income_tax, 44 | 請假扣薪: leavetime, 45 | }.merge(extra_loss) 46 | end 47 | 48 | def vendor 49 | contractor 50 | end 51 | 52 | def parttime 53 | { 54 | 勞保費: labor_insurance, 55 | 健保費: health_insurance, 56 | 二代健保: supplement_premium, 57 | 所得稅: income_tax, 58 | }.merge(extra_loss) 59 | end 60 | 61 | def advisor 62 | { 63 | 二代健保: supplement_premium, 64 | 所得稅: income_tax, 65 | }.merge(extra_loss) 66 | end 67 | 68 | def health_insurance 69 | salary.health_insurance 70 | end 71 | 72 | def extra_loss 73 | FormatService::ExtraDeductions.call(payroll) 74 | end 75 | 76 | def cleanup(hash) 77 | hash.delete_if { |_, value| value.zero? } 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/services/calculation_service/overtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module CalculationService 3 | class Overtime 4 | include Callable 5 | 6 | attr_reader :hours, :salary, :rate 7 | 8 | def initialize(overtime) 9 | @hours = overtime.hours 10 | @salary = overtime.payroll.salary 11 | @rate = overtime.rate 12 | end 13 | 14 | def call 15 | # 避免 statement.gain 寫入資料庫時被轉成字串,統一 to_i 16 | send(rate).to_i 17 | end 18 | 19 | private 20 | 21 | # 平日加班 22 | def weekday 23 | if hours <= 2 24 | initial_rate * hours 25 | else 26 | (initial_rate * 2) + (additional_rate * (hours - 2)) 27 | end 28 | end 29 | 30 | # 休息日加班(週六) 31 | def weekend 32 | if hours <= 8 33 | weekday 34 | else 35 | (initial_rate * 2) + (additional_rate * 6) + (final_rate * 4) 36 | end 37 | end 38 | 39 | # 例假日加班(國定假日) 40 | def holiday 41 | if hours <= 8 42 | holiday_rate 43 | elsif hours <= 10 44 | holiday_rate + (initial_rate * (hours - 8)) 45 | else 46 | holiday_rate + (initial_rate * 2) + (additional_rate * (hours - 10)) 47 | end 48 | end 49 | 50 | # 休假日加班(週日) 51 | def offday 52 | if hours <= 8 53 | holiday_rate 54 | else 55 | holiday_rate + (hourly_rate * 4 * 2) 56 | end 57 | end 58 | 59 | def hourly_rate 60 | (salary.income_with_subsidies / 30 / 8.0).ceil 61 | end 62 | 63 | def initial_rate 64 | (hourly_rate * 4 / 3.0).ceil 65 | end 66 | 67 | def additional_rate 68 | (hourly_rate * 5 / 3.0).ceil 69 | end 70 | 71 | def final_rate 72 | (hourly_rate * 8 / 3.0).ceil 73 | end 74 | 75 | def holiday_rate 76 | hourly_rate * 8 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/services/statement_service/builder_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "test_helper" 3 | 4 | module StatementService 5 | class BuilderTest < ActiveSupport::TestCase 6 | def test_builds_statement 7 | subject = prepare_subject(tax_code: "50", wage: 20000, insured: 0) 8 | StatementService::Builder.any_instance.expects(:statement).returns(Statement.new) 9 | Statement.any_instance.expects(:update).returns(true) 10 | assert StatementService::Builder.call(subject) 11 | end 12 | 13 | def test_build_unsplit_statement 14 | subject = prepare_subject(tax_code: "50", wage: 45000, insured: 45800, split: false) 15 | params = StatementService::Builder.new(subject).send(:params) 16 | assert_equal 45000, params[:amount] 17 | assert_nil params[:splits] 18 | end 19 | 20 | def test_build_split_statement 21 | subject = prepare_subject(tax_code: "9a", wage: 45000, insured: 0, split: true) 22 | params = StatementService::Builder.new(subject).send(:params) 23 | assert_equal 45000, params[:amount] 24 | assert_equal [20000, 20000, 5000], params[:splits] 25 | assert_equal 0, params[:excess_income] 26 | end 27 | 28 | private 29 | 30 | def prepare_subject(tax_code:, wage:, insured:, split: false) 31 | employee = build(:employee) 32 | term = build(:term, start_date: "2018-01-01", employee: employee) 33 | create( 34 | :payroll, 35 | year: 2018, 36 | month: 1, 37 | salary: build( 38 | :salary, 39 | tax_code: tax_code, 40 | monthly_wage: wage, 41 | insured_for_health: insured, 42 | insured_for_labor: insured, 43 | split: split, 44 | term: term 45 | ), 46 | employee: employee 47 | ) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/lib/statement_pdf_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class StatementPDFBuilder 3 | attr_reader :statement, :details, :renderer 4 | 5 | def initialize(statement) 6 | @statement = statement 7 | @details = FormatService::StatementDetails.call(statement) 8 | @renderer = StatementsController.renderer.new 9 | end 10 | 11 | def generate_pdf 12 | WickedPdf.new.pdf_from_string( 13 | html_content, 14 | encoding: "utf-8", 15 | orientation: "landscape" 16 | ) 17 | end 18 | 19 | def encrypted_pdf 20 | save_pdf 21 | encrypt_pdf 22 | File.read(encrypt_file_path) 23 | end 24 | 25 | def delete_files 26 | File.delete(pdf_file_path) 27 | File.delete(encrypt_file_path) 28 | end 29 | 30 | def filename 31 | "#{filename_prefix}-#{statement.employee_name}.pdf" 32 | end 33 | 34 | def email_subject 35 | filename_prefix 36 | end 37 | 38 | private 39 | 40 | def save_pdf 41 | File.write(pdf_file_path, generate_pdf, mode: "wb") 42 | end 43 | 44 | def encrypt_pdf 45 | PDF::Toolkit.pdftk(pdf_file_path, "output", encrypt_file_path, "user_pw", encrypt_password) 46 | end 47 | 48 | def pdf_file_path 49 | File.join(temp_dir, "source", filename) 50 | end 51 | 52 | def encrypt_file_path 53 | File.join(temp_dir, "encrypted", filename) 54 | end 55 | 56 | def temp_dir 57 | "tmp/pdfs" 58 | end 59 | 60 | def encrypt_password 61 | statement.employee_id_number 62 | end 63 | 64 | def filename_prefix 65 | "#{statement.year}-#{sprintf('%02d', statement.month)} 薪資明細" 66 | end 67 | 68 | def html_content 69 | renderer.render( 70 | layout: "pdf.pdf", 71 | template: details[:template], 72 | assigns: { details: details } 73 | ) 74 | end 75 | 76 | def template 77 | statement.splits? ? "split" : "show" 78 | end 79 | end 80 | --------------------------------------------------------------------------------