From 6ffdb566f6965cbf1da2c10dd63f9d8d43d0dac6 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 16:32:56 +0900 Subject: [PATCH 01/37] fix: update Dockerfile to create user and set permissions for app directory --- serverside_challenge_2/challenge/Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/serverside_challenge_2/challenge/Dockerfile b/serverside_challenge_2/challenge/Dockerfile index 166bd4432..90702f517 100644 --- a/serverside_challenge_2/challenge/Dockerfile +++ b/serverside_challenge_2/challenge/Dockerfile @@ -1,8 +1,11 @@ FROM ruby:3.1.2 + RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim -RUN mkdir /app +RUN useradd -ms /bin/bash rails +RUN mkdir /app && chown -R rails:rails /app +USER rails WORKDIR /app ADD Gemfile /app/Gemfile ADD Gemfile.lock /app/Gemfile.lock RUN bundle install -ADD . /app \ No newline at end of file +ADD . /app From a7b3a14e7da1ee1d603be7d931c63376ffa7f191 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 16:33:25 +0900 Subject: [PATCH 02/37] add rspec --- serverside_challenge_2/challenge/.rspec | 1 + serverside_challenge_2/challenge/Gemfile | 1 + serverside_challenge_2/challenge/Gemfile.lock | 19 ++++ .../challenge/spec/rails_helper.rb | 63 +++++++++++++ .../challenge/spec/spec_helper.rb | 94 +++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 serverside_challenge_2/challenge/.rspec create mode 100644 serverside_challenge_2/challenge/spec/rails_helper.rb create mode 100644 serverside_challenge_2/challenge/spec/spec_helper.rb diff --git a/serverside_challenge_2/challenge/.rspec b/serverside_challenge_2/challenge/.rspec new file mode 100644 index 000000000..c99d2e739 --- /dev/null +++ b/serverside_challenge_2/challenge/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/serverside_challenge_2/challenge/Gemfile b/serverside_challenge_2/challenge/Gemfile index 43bf67fe3..a469530c1 100644 --- a/serverside_challenge_2/challenge/Gemfile +++ b/serverside_challenge_2/challenge/Gemfile @@ -39,6 +39,7 @@ gem "bootsnap", require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] + gem 'rspec-rails', '~> 6.0' end group :development do diff --git a/serverside_challenge_2/challenge/Gemfile.lock b/serverside_challenge_2/challenge/Gemfile.lock index a47fb85f5..7aea3c032 100644 --- a/serverside_challenge_2/challenge/Gemfile.lock +++ b/serverside_challenge_2/challenge/Gemfile.lock @@ -75,6 +75,7 @@ GEM debug (1.9.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) erubi (1.12.0) globalid (1.2.1) activesupport (>= 6.1) @@ -153,6 +154,23 @@ GEM psych (>= 4.0.0) reline (0.4.2) io-console (~> 0.5) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.6) stringio (3.1.0) thor (1.3.0) timeout (0.4.1) @@ -173,6 +191,7 @@ DEPENDENCIES pg (~> 1.1) puma (~> 5.0) rails (~> 7.0.8) + rspec-rails (~> 6.0) tzinfo-data RUBY VERSION diff --git a/serverside_challenge_2/challenge/spec/rails_helper.rb b/serverside_challenge_2/challenge/spec/rails_helper.rb new file mode 100644 index 000000000..71c736775 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/rails_helper.rb @@ -0,0 +1,63 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Checks for pending migrations and applies them before tests are run. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_path = Rails.root.join('spec/fixtures') + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails can automatically mix in different behaviours to your tests + # based on their file location, for example enabling you to call `get` and + # `post` in specs under `spec/controllers`. + # + # You can disable this behaviour by removing the line below, and instead + # explicitly tag your specs with their type, e.g.: + # + # RSpec.describe UsersController, type: :controller do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/6-0/rspec-rails + config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/serverside_challenge_2/challenge/spec/spec_helper.rb b/serverside_challenge_2/challenge/spec/spec_helper.rb new file mode 100644 index 000000000..327b58ea1 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end From fe562c2a451523df295d0d76555ae8b63b808f72 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 16:34:17 +0900 Subject: [PATCH 03/37] add models and seeds --- .../challenge/app/models/basic_fee.rb | 3 + .../challenge/app/models/plan.rb | 5 ++ .../challenge/app/models/provider.rb | 3 + .../challenge/app/models/usage_charge.rb | 3 + .../20250928065553_create_providers.rb | 8 +++ .../db/migrate/20250928070016_create_plans.rb | 9 +++ .../20250928070125_create_basic_fees.rb | 13 +++++ .../20250928070132_create_usage_charges.rb | 13 +++++ serverside_challenge_2/challenge/db/schema.rb | 56 +++++++++++++++++++ serverside_challenge_2/challenge/db/seeds.rb | 35 ++++++++++++ .../challenge/db/seeds/basic_fees.csv | 26 +++++++++ .../challenge/db/seeds/plans.csv | 5 ++ .../challenge/db/seeds/providers.csv | 4 ++ .../challenge/db/seeds/usage_charges.csv | 11 ++++ .../challenge/spec/models/basic_fee_spec.rb | 5 ++ .../challenge/spec/models/plan_spec.rb | 5 ++ .../challenge/spec/models/provider_spec.rb | 5 ++ .../spec/models/usage_charge_spec.rb | 5 ++ 18 files changed, 214 insertions(+) create mode 100644 serverside_challenge_2/challenge/app/models/basic_fee.rb create mode 100644 serverside_challenge_2/challenge/app/models/plan.rb create mode 100644 serverside_challenge_2/challenge/app/models/provider.rb create mode 100644 serverside_challenge_2/challenge/app/models/usage_charge.rb create mode 100644 serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb create mode 100644 serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb create mode 100644 serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb create mode 100644 serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb create mode 100644 serverside_challenge_2/challenge/db/schema.rb create mode 100644 serverside_challenge_2/challenge/db/seeds/basic_fees.csv create mode 100644 serverside_challenge_2/challenge/db/seeds/plans.csv create mode 100644 serverside_challenge_2/challenge/db/seeds/providers.csv create mode 100644 serverside_challenge_2/challenge/db/seeds/usage_charges.csv create mode 100644 serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb create mode 100644 serverside_challenge_2/challenge/spec/models/plan_spec.rb create mode 100644 serverside_challenge_2/challenge/spec/models/provider_spec.rb create mode 100644 serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb new file mode 100644 index 000000000..bc9b2f62a --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -0,0 +1,3 @@ +class BasicFee < ApplicationRecord + belongs_to :plan +end diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb new file mode 100644 index 000000000..8e81c6ebc --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -0,0 +1,5 @@ +class Plan < ApplicationRecord + belongs_to :provider + has_many :basic_fees, dependent: :destroy + has_many :usage_charges, dependent: :destroy +end diff --git a/serverside_challenge_2/challenge/app/models/provider.rb b/serverside_challenge_2/challenge/app/models/provider.rb new file mode 100644 index 000000000..7f32b194a --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/provider.rb @@ -0,0 +1,3 @@ +class Provider < ApplicationRecord + has_many :plans, dependent: :destroy +end diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb new file mode 100644 index 000000000..817a179e1 --- /dev/null +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -0,0 +1,3 @@ +class UsageCharge < ApplicationRecord + belongs_to :plan +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb new file mode 100644 index 000000000..55e5907b8 --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb @@ -0,0 +1,8 @@ +class CreateProviders < ActiveRecord::Migration[7.0] + def change + create_table :providers do |t| + t.string :name, null: false + t.timestamps + end + end +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb new file mode 100644 index 000000000..8f5ca273e --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb @@ -0,0 +1,9 @@ +class CreatePlans < ActiveRecord::Migration[7.0] + def change + create_table :plans do |t| + t.references :provider, null: false, foreign_key: true + t.string :name, null: false + t.timestamps + end + end +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb new file mode 100644 index 000000000..11c11bb82 --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb @@ -0,0 +1,13 @@ +class CreateBasicFees < ActiveRecord::Migration[7.0] + def change + create_table :basic_fees do |t| + t.references :plan, null: false, foreign_key: true + t.integer :ampere, null: false, comment: "契約アンペア数(A)" + t.decimal :fee, precision: 10, scale: 2, null: false, comment: "基本料金(円)" + t.timestamps + end + + add_index :basic_fees, [:plan_id, :ampere], unique: true + add_index :basic_fees, :ampere + end +end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb new file mode 100644 index 000000000..06132505f --- /dev/null +++ b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb @@ -0,0 +1,13 @@ +class CreateUsageCharges < ActiveRecord::Migration[7.0] + def change + create_table :usage_charges do |t| + t.references :plan, null: false, foreign_key: true + t.integer :usage_lower, comment: "電気使用量(kWh) 下限" + t.integer :usage_upper, comment: "電気使用量(kWh) 上限" + t.decimal :unit_price, precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" + t.timestamps + end + + add_index :usage_charges, [:usage_lower, :usage_upper] + end +end diff --git a/serverside_challenge_2/challenge/db/schema.rb b/serverside_challenge_2/challenge/db/schema.rb new file mode 100644 index 000000000..f867f6fd4 --- /dev/null +++ b/serverside_challenge_2/challenge/db/schema.rb @@ -0,0 +1,56 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.0].define(version: 2025_09_28_070132) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "basic_fees", force: :cascade do |t| + t.bigint "plan_id", null: false + t.integer "ampere", null: false, comment: "契約アンペア数(A)" + t.decimal "fee", precision: 10, scale: 2, null: false, comment: "基本料金(円)" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["ampere"], name: "index_basic_fees_on_ampere" + t.index ["plan_id", "ampere"], name: "index_basic_fees_on_plan_id_and_ampere", unique: true + t.index ["plan_id"], name: "index_basic_fees_on_plan_id" + end + + create_table "plans", force: :cascade do |t| + t.bigint "provider_id", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["provider_id"], name: "index_plans_on_provider_id" + end + + create_table "providers", force: :cascade do |t| + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "usage_charges", force: :cascade do |t| + t.bigint "plan_id", null: false + t.integer "usage_lower", comment: "電気使用量(kWh) 下限" + t.integer "usage_upper", comment: "電気使用量(kWh) 上限" + t.decimal "unit_price", precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["plan_id"], name: "index_usage_charges_on_plan_id" + t.index ["usage_lower", "usage_upper"], name: "index_usage_charges_on_usage_lower_and_usage_upper" + end + + add_foreign_key "basic_fees", "plans" + add_foreign_key "plans", "providers" + add_foreign_key "usage_charges", "plans" +end diff --git a/serverside_challenge_2/challenge/db/seeds.rb b/serverside_challenge_2/challenge/db/seeds.rb index bc25fce30..ba6a344a5 100644 --- a/serverside_challenge_2/challenge/db/seeds.rb +++ b/serverside_challenge_2/challenge/db/seeds.rb @@ -5,3 +5,38 @@ # # movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) # Character.create(name: "Luke", movie: movies.first) + +require 'csv' +csv_dir = Rails.root.join("db", "seeds") + +CSV.foreach(csv_dir.join("providers.csv"), headers: true) do |row| + Provider.create!( + id: row["id"], + name: row["name"], + ) +end + +CSV.foreach(csv_dir.join("plans.csv"), headers: true) do |row| + Plan.create!( + id: row["id"], + provider_id: row["provider_id"], + name: row["name"], + ) +end + +CSV.foreach(csv_dir.join("basic_fees.csv"), headers: true) do |row| + BasicFee.create!( + plan_id: row["plan_id"], + ampere: row["ampere"], + fee: row["fee"], + ) +end + +CSV.foreach(csv_dir.join("usage_charges.csv"), headers: true) do |row| + UsageCharge.create!( + plan_id: row["plan_id"], + usage_lower: row["usage_lower"], + usage_upper: row["usage_upper"], + unit_price: row["unit_price"], + ) +end \ No newline at end of file diff --git a/serverside_challenge_2/challenge/db/seeds/basic_fees.csv b/serverside_challenge_2/challenge/db/seeds/basic_fees.csv new file mode 100644 index 000000000..6240a9ca5 --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/basic_fees.csv @@ -0,0 +1,26 @@ +id,plan_id,ampere,fee +1,1,10,286.00 +2,1,15,429.00 +3,1,20,572.00 +4,1,30,858.00 +5,1,40,1144.00 +6,1,50,1430.00 +7,1,60,1716.00 +8,2,10,311.75 +9,2,15,467.63 +10,2,20,623.50 +11,2,30,935.25 +12,2,40,1247.00 +13,2,50,1558.75 +14,2,60,1870.50 +15,3,30,858.00 +16,3,40,1144.00 +17,3,50,1430.00 +18,3,60,1716.00 +19,4,10,0.00 +20,4,15,0.00 +21,4,20,0.00 +22,4,30,0.00 +23,4,40,0.00 +24,4,50,0.00 +25,4,60,0.00 diff --git a/serverside_challenge_2/challenge/db/seeds/plans.csv b/serverside_challenge_2/challenge/db/seeds/plans.csv new file mode 100644 index 000000000..309d868d3 --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/plans.csv @@ -0,0 +1,5 @@ +id,provider_id,name +1,1,従量電灯B +2,1,スタンダードS +3,2,ずっとも電気1 +4,3,おうちプラン diff --git a/serverside_challenge_2/challenge/db/seeds/providers.csv b/serverside_challenge_2/challenge/db/seeds/providers.csv new file mode 100644 index 000000000..bd728ab31 --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/providers.csv @@ -0,0 +1,4 @@ +id,name +1,東京電力エナジーパートナー +2,東京ガス +3,Looopでんき \ No newline at end of file diff --git a/serverside_challenge_2/challenge/db/seeds/usage_charges.csv b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv new file mode 100644 index 000000000..558cc380e --- /dev/null +++ b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv @@ -0,0 +1,11 @@ +id,plan_id,usage_lower,usage_upper,unit_price +1,1,0,120,19.88 +2,1,121,300,26.48 +3,1,301,,30.57 +4,2,0,120,29.80 +5,2,121,300,36.40 +6,2,301,,40.49 +7,3,0,140,23.67 +8,3,141,350,23.88 +9,3,351,,26.41 +10,4,0,,28.8 diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb new file mode 100644 index 000000000..c1e74b8cd --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe BasicFee, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb new file mode 100644 index 000000000..c0be975b5 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Plan, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/serverside_challenge_2/challenge/spec/models/provider_spec.rb b/serverside_challenge_2/challenge/spec/models/provider_spec.rb new file mode 100644 index 000000000..5e672c777 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/provider_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Provider, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb new file mode 100644 index 000000000..7700ccdac --- /dev/null +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe UsageCharge, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 7f4c5384a11b6f5d5e65674d6b005b04c9508897 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 16:59:06 +0900 Subject: [PATCH 04/37] =?UTF-8?q?usage=5Fcharges.usage=5Flower=E3=82=92nul?= =?UTF-8?q?l:=20false=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../challenge/db/migrate/20250928070132_create_usage_charges.rb | 2 +- serverside_challenge_2/challenge/db/schema.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb index 06132505f..c42e00777 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb @@ -2,7 +2,7 @@ class CreateUsageCharges < ActiveRecord::Migration[7.0] def change create_table :usage_charges do |t| t.references :plan, null: false, foreign_key: true - t.integer :usage_lower, comment: "電気使用量(kWh) 下限" + t.integer :usage_lower, null: false, comment: "電気使用量(kWh) 下限" t.integer :usage_upper, comment: "電気使用量(kWh) 上限" t.decimal :unit_price, precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" t.timestamps diff --git a/serverside_challenge_2/challenge/db/schema.rb b/serverside_challenge_2/challenge/db/schema.rb index f867f6fd4..ed6befb44 100644 --- a/serverside_challenge_2/challenge/db/schema.rb +++ b/serverside_challenge_2/challenge/db/schema.rb @@ -41,7 +41,7 @@ create_table "usage_charges", force: :cascade do |t| t.bigint "plan_id", null: false - t.integer "usage_lower", comment: "電気使用量(kWh) 下限" + t.integer "usage_lower", null: false, comment: "電気使用量(kWh) 下限" t.integer "usage_upper", comment: "電気使用量(kWh) 上限" t.decimal "unit_price", precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" t.datetime "created_at", null: false From 61af93168d2819bd972fdd78c159f15a3338b54f Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 17:02:09 +0900 Subject: [PATCH 05/37] =?UTF-8?q?BasicFee=E3=81=A8UsageCharge=E3=81=AEscop?= =?UTF-8?q?e=E8=BF=BD=E5=8A=A0=E3=80=82rspec=E5=91=A8=E3=82=8A=E3=81=AEgem?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serverside_challenge_2/challenge/Gemfile | 2 ++ serverside_challenge_2/challenge/Gemfile.lock | 9 +++++++ .../challenge/app/models/basic_fee.rb | 3 +++ .../challenge/app/models/usage_charge.rb | 4 ++++ .../challenge/spec/factories/basic_fees.rb | 7 ++++++ .../challenge/spec/factories/plans.rb | 6 +++++ .../challenge/spec/factories/providers.rb | 5 ++++ .../challenge/spec/factories/usage_charges.rb | 8 +++++++ .../challenge/spec/models/basic_fee_spec.rb | 20 +++++++++++++++- .../challenge/spec/models/plan_spec.rb | 6 ++++- .../challenge/spec/models/provider_spec.rb | 4 +++- .../spec/models/usage_charge_spec.rb | 24 ++++++++++++++++++- .../challenge/spec/rails_helper.rb | 9 +++++++ 13 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 serverside_challenge_2/challenge/spec/factories/basic_fees.rb create mode 100644 serverside_challenge_2/challenge/spec/factories/plans.rb create mode 100644 serverside_challenge_2/challenge/spec/factories/providers.rb create mode 100644 serverside_challenge_2/challenge/spec/factories/usage_charges.rb diff --git a/serverside_challenge_2/challenge/Gemfile b/serverside_challenge_2/challenge/Gemfile index a469530c1..cf483b205 100644 --- a/serverside_challenge_2/challenge/Gemfile +++ b/serverside_challenge_2/challenge/Gemfile @@ -40,6 +40,8 @@ group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] gem 'rspec-rails', '~> 6.0' + gem 'shoulda-matchers', '~> 5.0' + gem 'factory_bot_rails' end group :development do diff --git a/serverside_challenge_2/challenge/Gemfile.lock b/serverside_challenge_2/challenge/Gemfile.lock index 7aea3c032..8a808ae5b 100644 --- a/serverside_challenge_2/challenge/Gemfile.lock +++ b/serverside_challenge_2/challenge/Gemfile.lock @@ -77,6 +77,11 @@ GEM reline (>= 0.3.8) diff-lcs (1.6.2) erubi (1.12.0) + factory_bot (6.5.5) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.1) @@ -171,6 +176,8 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.6) + shoulda-matchers (5.3.0) + activesupport (>= 5.2.0) stringio (3.1.0) thor (1.3.0) timeout (0.4.1) @@ -188,10 +195,12 @@ PLATFORMS DEPENDENCIES bootsnap debug + factory_bot_rails pg (~> 1.1) puma (~> 5.0) rails (~> 7.0.8) rspec-rails (~> 6.0) + shoulda-matchers (~> 5.0) tzinfo-data RUBY VERSION diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb index bc9b2f62a..35ddc0d43 100644 --- a/serverside_challenge_2/challenge/app/models/basic_fee.rb +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -1,3 +1,6 @@ class BasicFee < ApplicationRecord belongs_to :plan + + # @param ampere [Integer] 契約アンペア数(A) + scope :by_ampere, ->(ampere) { where(ampere:) } end diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index 817a179e1..b1faf6b93 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -1,3 +1,7 @@ class UsageCharge < ApplicationRecord belongs_to :plan + + # @param usage [Integer] 電気使用量(kWh) + # NOTE: usage_upper = nullの場合は上限なしとみなす + scope :by_usage, ->(usage) { where("usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?)", usage, usage) } end diff --git a/serverside_challenge_2/challenge/spec/factories/basic_fees.rb b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb new file mode 100644 index 000000000..6d6c57d9d --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :basic_fee do + plan + ampere { rand(10..60) } + fee { rand(500.00..2000.00).round(2) } + end +end diff --git a/serverside_challenge_2/challenge/spec/factories/plans.rb b/serverside_challenge_2/challenge/spec/factories/plans.rb new file mode 100644 index 000000000..27b3172d7 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/plans.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :plan do + provider + sequence(:name) { |n| "Plan_#{n}" } + end +end diff --git a/serverside_challenge_2/challenge/spec/factories/providers.rb b/serverside_challenge_2/challenge/spec/factories/providers.rb new file mode 100644 index 000000000..0681d1b8b --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/providers.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :provider do + sequence(:name) { |n| "Provider_#{n}" } + end +end diff --git a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb new file mode 100644 index 000000000..cf235d405 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :usage_charge do + plan + usage_lower { rand(0..100) } + usage_upper { usage_lower + rand(1..100) } + unit_price { rand(10.00..50.00).round(2) } + end +end diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb index c1e74b8cd..1c50d9568 100644 --- a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -1,5 +1,23 @@ require 'rails_helper' RSpec.describe BasicFee, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { is_expected.to belong_to(:plan) } + end + + describe 'scopes' do + describe '.by_ampere' do + subject { described_class.by_ampere(ampere) } + + let(:ampere) { 30 } + + let!(:basic_fee_30A) { create(:basic_fee, ampere: 30) } + let!(:basic_fee_40A) { create(:basic_fee, ampere: 40) } + let!(:basic_fee_50A) { create(:basic_fee, ampere: 50) } + + it 'returns basic fees with the specified ampere' do + expect(subject).to contain_exactly(basic_fee_30A) + end + end + end end diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index c0be975b5..03ee578c0 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -1,5 +1,9 @@ require 'rails_helper' RSpec.describe Plan, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { is_expected.to belong_to(:provider) } + it { is_expected.to have_many(:basic_fees).dependent(:destroy) } + it { is_expected.to have_many(:usage_charges).dependent(:destroy) } + end end diff --git a/serverside_challenge_2/challenge/spec/models/provider_spec.rb b/serverside_challenge_2/challenge/spec/models/provider_spec.rb index 5e672c777..ab161dc04 100644 --- a/serverside_challenge_2/challenge/spec/models/provider_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/provider_spec.rb @@ -1,5 +1,7 @@ require 'rails_helper' RSpec.describe Provider, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { is_expected.to have_many(:plans).dependent(:destroy) } + end end diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index 7700ccdac..2e371c74e 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -1,5 +1,27 @@ require 'rails_helper' RSpec.describe UsageCharge, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { is_expected.to belong_to(:plan) } + end + + describe 'scopes' do + describe '.by_usage' do + subject { described_class.by_usage(usage) } + + let(:usage) { 100 } + + let!(:usage_charge_1) { create(:usage_charge, usage_lower: 0, usage_upper: 100) } + let!(:usage_charge_2) { create(:usage_charge, usage_lower: 100, usage_upper: 200) } + let!(:usage_charge_3) { create(:usage_charge, usage_lower: 100, usage_upper: nil) } + + let!(:usage_charge_4) { create(:usage_charge, usage_lower: 0, usage_upper: 99) } + let!(:usage_charge_5) { create(:usage_charge, usage_lower: 101, usage_upper: 200) } + let!(:usage_charge_6) { create(:usage_charge, usage_lower: 101, usage_upper: nil) } +\ + it 'returns usage charges that include the specified usage' do + expect(subject).to contain_exactly(usage_charge_1, usage_charge_2, usage_charge_3) + end + end + end end diff --git a/serverside_challenge_2/challenge/spec/rails_helper.rb b/serverside_challenge_2/challenge/spec/rails_helper.rb index 71c736775..25f23190e 100644 --- a/serverside_challenge_2/challenge/spec/rails_helper.rb +++ b/serverside_challenge_2/challenge/spec/rails_helper.rb @@ -60,4 +60,13 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + + Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end + end + # FactoryBot configuration + config.include FactoryBot::Syntax::Methods end From fbd5c5a4196f67a87eaf6af964b81aa8d17f8ed5 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 17:05:32 +0900 Subject: [PATCH 06/37] =?UTF-8?q?annotate=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serverside_challenge_2/challenge/Gemfile | 1 + serverside_challenge_2/challenge/Gemfile.lock | 4 ++ .../challenge/app/models/basic_fee.rb | 22 +++++++ .../challenge/app/models/plan.rb | 19 ++++++ .../challenge/app/models/provider.rb | 10 ++++ .../challenge/app/models/usage_charge.rb | 22 +++++++ .../lib/tasks/auto_annotate_models.rake | 59 +++++++++++++++++++ .../challenge/spec/factories/basic_fees.rb | 22 +++++++ .../challenge/spec/factories/plans.rb | 19 ++++++ .../challenge/spec/factories/providers.rb | 10 ++++ .../challenge/spec/factories/usage_charges.rb | 22 +++++++ .../challenge/spec/models/basic_fee_spec.rb | 22 +++++++ .../challenge/spec/models/plan_spec.rb | 19 ++++++ .../challenge/spec/models/provider_spec.rb | 10 ++++ .../spec/models/usage_charge_spec.rb | 22 +++++++ 15 files changed, 283 insertions(+) create mode 100644 serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake diff --git a/serverside_challenge_2/challenge/Gemfile b/serverside_challenge_2/challenge/Gemfile index cf483b205..3d6fabe41 100644 --- a/serverside_challenge_2/challenge/Gemfile +++ b/serverside_challenge_2/challenge/Gemfile @@ -47,5 +47,6 @@ end group :development do # Speed up commands on slow machines / big apps [https://github.com/rails/spring] # gem "spring" + gem 'annotate' end diff --git a/serverside_challenge_2/challenge/Gemfile.lock b/serverside_challenge_2/challenge/Gemfile.lock index 8a808ae5b..54d2d315c 100644 --- a/serverside_challenge_2/challenge/Gemfile.lock +++ b/serverside_challenge_2/challenge/Gemfile.lock @@ -66,6 +66,9 @@ GEM i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) + annotate (3.2.0) + activerecord (>= 3.2, < 8.0) + rake (>= 10.4, < 14.0) bootsnap (1.18.3) msgpack (~> 1.2) builder (3.2.4) @@ -193,6 +196,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + annotate bootsnap debug factory_bot_rails diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb index 35ddc0d43..aa6ebf45c 100644 --- a/serverside_challenge_2/challenge/app/models/basic_fee.rb +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -4,3 +4,25 @@ class BasicFee < ApplicationRecord # @param ampere [Integer] 契約アンペア数(A) scope :by_ampere, ->(ampere) { where(ampere:) } end + +# == Schema Information +# +# Table name: basic_fees +# +# id :bigint not null, primary key +# ampere(契約アンペア数(A)) :integer not null +# fee(基本料金(円)) :decimal(10, 2) not null +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint not null +# +# Indexes +# +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_plan_id (plan_id) +# index_basic_fees_on_plan_id_and_ampere (plan_id,ampere) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index 8e81c6ebc..2b30296cf 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -3,3 +3,22 @@ class Plan < ApplicationRecord has_many :basic_fees, dependent: :destroy has_many :usage_charges, dependent: :destroy end + +# == Schema Information +# +# Table name: plans +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# provider_id :bigint not null +# +# Indexes +# +# index_plans_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (provider_id => providers.id) +# diff --git a/serverside_challenge_2/challenge/app/models/provider.rb b/serverside_challenge_2/challenge/app/models/provider.rb index 7f32b194a..fe3e3b9cf 100644 --- a/serverside_challenge_2/challenge/app/models/provider.rb +++ b/serverside_challenge_2/challenge/app/models/provider.rb @@ -1,3 +1,13 @@ class Provider < ApplicationRecord has_many :plans, dependent: :destroy end + +# == Schema Information +# +# Table name: providers +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index b1faf6b93..6fd2f6f00 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -5,3 +5,25 @@ class UsageCharge < ApplicationRecord # NOTE: usage_upper = nullの場合は上限なしとみなす scope :by_usage, ->(usage) { where("usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?)", usage, usage) } end + +# == Schema Information +# +# Table name: usage_charges +# +# id :bigint not null, primary key +# unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null +# usage_lower(電気使用量(kWh) 下限) :integer not null +# usage_upper(電気使用量(kWh) 上限) :integer +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint not null +# +# Indexes +# +# index_usage_charges_on_plan_id (plan_id) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# diff --git a/serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake b/serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake new file mode 100644 index 000000000..b7a09c439 --- /dev/null +++ b/serverside_challenge_2/challenge/lib/tasks/auto_annotate_models.rake @@ -0,0 +1,59 @@ +# NOTE: only doing this in development as some production environments (Heroku) +# NOTE: are sensitive to local FS writes, and besides -- it's just not proper +# NOTE: to have a dev-mode tool do its thing in production. +if Rails.env.development? + require 'annotate' + task :set_annotation_options do + # You can override any of these by setting an environment variable of the + # same name. + Annotate.set_defaults( + 'active_admin' => 'false', + 'additional_file_patterns' => [], + 'routes' => 'false', + 'models' => 'true', + 'position_in_routes' => 'after', + 'position_in_class' => 'after', + 'position_in_test' => 'after', + 'position_in_fixture' => 'after', + 'position_in_factory' => 'after', + 'position_in_serializer' => 'after', + 'show_foreign_keys' => 'true', + 'show_complete_foreign_keys' => 'false', + 'show_indexes' => 'true', + 'simple_indexes' => 'false', + 'model_dir' => 'app/models', + 'root_dir' => '', + 'include_version' => 'false', + 'require' => '', + 'exclude_tests' => 'false', + 'exclude_fixtures' => 'false', + 'exclude_factories' => 'false', + 'exclude_serializers' => 'false', + 'exclude_scaffolds' => 'true', + 'exclude_controllers' => 'true', + 'exclude_helpers' => 'true', + 'exclude_sti_subclasses' => 'false', + 'ignore_model_sub_dir' => 'false', + 'ignore_columns' => nil, + 'ignore_routes' => nil, + 'ignore_unknown_models' => 'false', + 'hide_limit_column_types' => 'integer,bigint,boolean', + 'hide_default_column_types' => 'json,jsonb,hstore', + 'skip_on_db_migrate' => 'false', + 'format_bare' => 'true', + 'format_rdoc' => 'false', + 'format_yard' => 'false', + 'format_markdown' => 'false', + 'sort' => 'false', + 'force' => 'false', + 'frozen' => 'false', + 'classified_sort' => 'true', + 'trace' => 'false', + 'wrapper_open' => nil, + 'wrapper_close' => nil, + 'with_comment' => 'true' + ) + end + + Annotate.load_tasks +end diff --git a/serverside_challenge_2/challenge/spec/factories/basic_fees.rb b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb index 6d6c57d9d..b5429d0d2 100644 --- a/serverside_challenge_2/challenge/spec/factories/basic_fees.rb +++ b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb @@ -5,3 +5,25 @@ fee { rand(500.00..2000.00).round(2) } end end + +# == Schema Information +# +# Table name: basic_fees +# +# id :bigint not null, primary key +# ampere(契約アンペア数(A)) :integer not null +# fee(基本料金(円)) :decimal(10, 2) not null +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint not null +# +# Indexes +# +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_plan_id (plan_id) +# index_basic_fees_on_plan_id_and_ampere (plan_id,ampere) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# diff --git a/serverside_challenge_2/challenge/spec/factories/plans.rb b/serverside_challenge_2/challenge/spec/factories/plans.rb index 27b3172d7..0e4a2b7ca 100644 --- a/serverside_challenge_2/challenge/spec/factories/plans.rb +++ b/serverside_challenge_2/challenge/spec/factories/plans.rb @@ -4,3 +4,22 @@ sequence(:name) { |n| "Plan_#{n}" } end end + +# == Schema Information +# +# Table name: plans +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# provider_id :bigint not null +# +# Indexes +# +# index_plans_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (provider_id => providers.id) +# diff --git a/serverside_challenge_2/challenge/spec/factories/providers.rb b/serverside_challenge_2/challenge/spec/factories/providers.rb index 0681d1b8b..ffb48fdcf 100644 --- a/serverside_challenge_2/challenge/spec/factories/providers.rb +++ b/serverside_challenge_2/challenge/spec/factories/providers.rb @@ -3,3 +3,13 @@ sequence(:name) { |n| "Provider_#{n}" } end end + +# == Schema Information +# +# Table name: providers +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb index cf235d405..af042ee4d 100644 --- a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb +++ b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb @@ -6,3 +6,25 @@ unit_price { rand(10.00..50.00).round(2) } end end + +# == Schema Information +# +# Table name: usage_charges +# +# id :bigint not null, primary key +# unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null +# usage_lower(電気使用量(kWh) 下限) :integer not null +# usage_upper(電気使用量(kWh) 上限) :integer +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint not null +# +# Indexes +# +# index_usage_charges_on_plan_id (plan_id) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb index 1c50d9568..fb409e73c 100644 --- a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -21,3 +21,25 @@ end end end + +# == Schema Information +# +# Table name: basic_fees +# +# id :bigint not null, primary key +# ampere(契約アンペア数(A)) :integer not null +# fee(基本料金(円)) :decimal(10, 2) not null +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint not null +# +# Indexes +# +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_plan_id (plan_id) +# index_basic_fees_on_plan_id_and_ampere (plan_id,ampere) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index 03ee578c0..0e6c14cf8 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -7,3 +7,22 @@ it { is_expected.to have_many(:usage_charges).dependent(:destroy) } end end + +# == Schema Information +# +# Table name: plans +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# provider_id :bigint not null +# +# Indexes +# +# index_plans_on_provider_id (provider_id) +# +# Foreign Keys +# +# fk_rails_... (provider_id => providers.id) +# diff --git a/serverside_challenge_2/challenge/spec/models/provider_spec.rb b/serverside_challenge_2/challenge/spec/models/provider_spec.rb index ab161dc04..fa0fb8640 100644 --- a/serverside_challenge_2/challenge/spec/models/provider_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/provider_spec.rb @@ -5,3 +5,13 @@ it { is_expected.to have_many(:plans).dependent(:destroy) } end end + +# == Schema Information +# +# Table name: providers +# +# id :bigint not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index 2e371c74e..df3b59b06 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -25,3 +25,25 @@ end end end + +# == Schema Information +# +# Table name: usage_charges +# +# id :bigint not null, primary key +# unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null +# usage_lower(電気使用量(kWh) 下限) :integer not null +# usage_upper(電気使用量(kWh) 上限) :integer +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint not null +# +# Indexes +# +# index_usage_charges_on_plan_id (plan_id) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# From 8346e6dda814aa3f52ae78dc6ab692bb1432f767 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 17:11:53 +0900 Subject: [PATCH 07/37] Add comments to database tables for better clarity --- .../db/migrate/20250928065553_create_providers.rb | 2 +- .../challenge/db/migrate/20250928070016_create_plans.rb | 2 +- .../db/migrate/20250928070125_create_basic_fees.rb | 2 +- .../db/migrate/20250928070132_create_usage_charges.rb | 2 +- serverside_challenge_2/challenge/db/schema.rb | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb index 55e5907b8..92200bf67 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb @@ -1,6 +1,6 @@ class CreateProviders < ActiveRecord::Migration[7.0] def change - create_table :providers do |t| + create_table :providers, comment: '電力会社' do |t| t.string :name, null: false t.timestamps end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb index 8f5ca273e..264fb3770 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb @@ -1,6 +1,6 @@ class CreatePlans < ActiveRecord::Migration[7.0] def change - create_table :plans do |t| + create_table :plans, comment: 'プラン' do |t| t.references :provider, null: false, foreign_key: true t.string :name, null: false t.timestamps diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb index 11c11bb82..06bec2003 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb @@ -1,6 +1,6 @@ class CreateBasicFees < ActiveRecord::Migration[7.0] def change - create_table :basic_fees do |t| + create_table :basic_fees, comment: '基本料金' do |t| t.references :plan, null: false, foreign_key: true t.integer :ampere, null: false, comment: "契約アンペア数(A)" t.decimal :fee, precision: 10, scale: 2, null: false, comment: "基本料金(円)" diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb index c42e00777..9b0799f00 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb @@ -1,6 +1,6 @@ class CreateUsageCharges < ActiveRecord::Migration[7.0] def change - create_table :usage_charges do |t| + create_table :usage_charges, comment: '従量料金' do |t| t.references :plan, null: false, foreign_key: true t.integer :usage_lower, null: false, comment: "電気使用量(kWh) 下限" t.integer :usage_upper, comment: "電気使用量(kWh) 上限" diff --git a/serverside_challenge_2/challenge/db/schema.rb b/serverside_challenge_2/challenge/db/schema.rb index ed6befb44..d99c169be 100644 --- a/serverside_challenge_2/challenge/db/schema.rb +++ b/serverside_challenge_2/challenge/db/schema.rb @@ -14,7 +14,7 @@ # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" - create_table "basic_fees", force: :cascade do |t| + create_table "basic_fees", comment: "基本料金", force: :cascade do |t| t.bigint "plan_id", null: false t.integer "ampere", null: false, comment: "契約アンペア数(A)" t.decimal "fee", precision: 10, scale: 2, null: false, comment: "基本料金(円)" @@ -25,7 +25,7 @@ t.index ["plan_id"], name: "index_basic_fees_on_plan_id" end - create_table "plans", force: :cascade do |t| + create_table "plans", comment: "プラン", force: :cascade do |t| t.bigint "provider_id", null: false t.string "name", null: false t.datetime "created_at", null: false @@ -33,13 +33,13 @@ t.index ["provider_id"], name: "index_plans_on_provider_id" end - create_table "providers", force: :cascade do |t| + create_table "providers", comment: "電力会社", force: :cascade do |t| t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "usage_charges", force: :cascade do |t| + create_table "usage_charges", comment: "従量料金", force: :cascade do |t| t.bigint "plan_id", null: false t.integer "usage_lower", null: false, comment: "電気使用量(kWh) 下限" t.integer "usage_upper", comment: "電気使用量(kWh) 上限" From 26fe6737123b5abd317abdfebc0d20f657d17a6d Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 19:21:16 +0900 Subject: [PATCH 08/37] =?UTF-8?q?=E3=83=93=E3=82=B8=E3=83=8D=E3=82=B9?= =?UTF-8?q?=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../challenge/app/models/plan.rb | 46 ++++ .../challenge/app/models/usage_charge.rb | 6 + .../challenge/spec/models/basic_fee_spec.rb | 2 +- .../challenge/spec/models/plan_spec.rb | 199 ++++++++++++++++++ .../spec/models/usage_charge_spec.rb | 13 +- 5 files changed, 264 insertions(+), 2 deletions(-) diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index 2b30296cf..a56fa9371 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -2,6 +2,52 @@ class Plan < ApplicationRecord belongs_to :provider has_many :basic_fees, dependent: :destroy has_many :usage_charges, dependent: :destroy + + # 契約アンペア数と電気使用量に基づき、該当する料金プランの一覧を取得する + # @param ampere [Integer] 契約アンペア数(A) + # @param usage [Integer] 電気使用量(kWh) + # @return [ActiveRecord::Relation] + scope :by_ampere_and_usage, ->(ampere, usage) do + joins(:basic_fees, :usage_charges) + .includes(:provider, :basic_fees, :usage_charges) + .merge(BasicFee.by_ampere(ampere)) + .merge(UsageCharge.by_usage(usage)) + .distinct + end + + # 契約アンペア数と電気使用量に基づき、該当する料金プランの一覧と料金を取得する + # @param ampere [Integer] 契約アンペア数(A) + # @param usage [Integer] 電気使用量(kWh) + # @return [Array] [{ provider_name: ‘Looopでんき’, plan_name: ‘おうちプラン’, price: ‘1234’ }, …] + def self.plan_prices(ampere:, usage:) + # Integerであること + raise ArgumentError, 'ampere must be an Integer' unless ampere.is_a?(Integer) + raise ArgumentError, 'usage must be an Integer' unless usage.is_a?(Integer) + + plans = self.by_ampere_and_usage(ampere, usage) + plans.map do |plan| + basic_fee = plan.basic_fees.first + usage_charge = plan.usage_charges.first + + { + provider_name: plan.provider.name, + plan_name: plan.name, + price: self.calc_price(basic_fee:, usage_charge:, usage:), + } + end + end + + private + + # @param basic_fee [BasicFee] 基本料金オブジェクト + # @param usage_charge [UsageCharge] 従量料金オブジェクト + # @param usage [Integer] 電気使用量(kWh) + # @return [Integer] 合計電気料金 (円) + def self.calc_price(basic_fee:, usage_charge:, usage:) + fee = basic_fee.fee + charge = usage_charge.calc_charge(usage) + (fee + charge).floor # TODO: 小数点以下の扱い + end end # == Schema Information diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index 6fd2f6f00..b41334eaa 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -4,6 +4,12 @@ class UsageCharge < ApplicationRecord # @param usage [Integer] 電気使用量(kWh) # NOTE: usage_upper = nullの場合は上限なしとみなす scope :by_usage, ->(usage) { where("usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?)", usage, usage) } + + # @param usage [Integer] 電気使用量(kWh) + # @return [BigDecimal] 従量料金(円) + def calc_charge(usage) + unit_price * usage + end end # == Schema Information diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb index fb409e73c..91bdf64ef 100644 --- a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -8,7 +8,7 @@ describe 'scopes' do describe '.by_ampere' do subject { described_class.by_ampere(ampere) } - + let(:ampere) { 30 } let!(:basic_fee_30A) { create(:basic_fee, ampere: 30) } diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index 0e6c14cf8..84d3429f3 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -6,6 +6,205 @@ it { is_expected.to have_many(:basic_fees).dependent(:destroy) } it { is_expected.to have_many(:usage_charges).dependent(:destroy) } end + + describe 'scopes' do + describe '.by_ampere_and_usage' do + subject { described_class.by_ampere_and_usage(ampere, usage) } + + let(:plan) { create(:plan) } + let!(:basic_fee) { create(:basic_fee, plan:, ampere: 30) } + let!(:usage_charge) { create(:usage_charge, plan:, usage_lower: 10, usage_upper: 100) } + + context 'アンペアと使用量に該当するプランがある場合' do + let(:ampere) { basic_fee.ampere } + let(:usage) { usage_charge.usage_lower } + + it 'returns target plans' do + is_expected.to include(plan) + end + end + + context 'アンペアに該当するものがない場合' do + let(:ampere) { basic_fee.ampere - 1 } + let(:usage) { usage_charge.usage_lower } + + it 'returns an empty' do + is_expected.to be_empty + end + end + context '使用料に該当するものがない場合' do + let(:ampere) { basic_fee.ampere } + let(:usage) { usage_charge.usage_lower - 1 } + + it 'returns an empty' do + is_expected.to be_empty + end + end + end + end + + describe '.plan_prices' do + subject { described_class.plan_prices(ampere:, usage:) } + + let(:provider1) { create(:provider, name: '東京電力エナジーパートナー') } + let(:provider2) { create(:provider, name: '東京ガス') } + let(:provider3) { create(:provider, name: 'Looopでんき') } + let(:plan1) { create(:plan, name: '従量電灯B', provider: provider1) } + let(:plan2) { create(:plan, name: 'スタンダードS', provider: provider1) } + let(:plan3) { create(:plan, name: 'ずっとも電気1', provider: provider2) } + let(:plan4) { create(:plan, name: 'おうちプラン', provider: provider3) } + let!(:basic_fee1) { create(:basic_fee, plan: plan1, ampere: 10, fee: 286.0) } + let!(:basic_fee2) { create(:basic_fee, plan: plan1, ampere: 30, fee: 858.00) } + let!(:basic_fee3) { create(:basic_fee, plan: plan2, ampere: 10, fee: 311.75) } + let!(:basic_fee4) { create(:basic_fee, plan: plan2, ampere: 30, fee: 935.25) } + let!(:basic_fee5) { create(:basic_fee, plan: plan3, ampere: 30, fee: 858.00) } + let!(:basic_fee6) { create(:basic_fee, plan: plan4, ampere: 10, fee: 0.00) } + let!(:basic_fee7) { create(:basic_fee, plan: plan4, ampere: 30, fee: 0.00) } + let!(:usage_charge1) { create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let!(:usage_charge2) { create(:usage_charge, plan: plan1, usage_lower: 121, usage_upper: 300, unit_price: 26.48) } + let!(:usage_charge3) { create(:usage_charge, plan: plan1, usage_lower: 301, usage_upper: nil, unit_price: 30.57) } + let!(:usage_charge4) { create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: 120, unit_price: 29.80) } + let!(:usage_charge5) { create(:usage_charge, plan: plan2, usage_lower: 121, usage_upper: 300, unit_price: 36.40) } + let!(:usage_charge6) { create(:usage_charge, plan: plan2, usage_lower: 301, usage_upper: nil, unit_price: 40.49) } + let!(:usage_charge7) { create(:usage_charge, plan: plan3, usage_lower: 0, usage_upper: 120, unit_price: 23.67) } + let!(:usage_charge8) { create(:usage_charge, plan: plan3, usage_lower: 121, usage_upper: 300, unit_price: 23.88) } + let!(:usage_charge9) { create(:usage_charge, plan: plan3, usage_lower: 301, usage_upper: nil, unit_price: 26.41) } + let!(:usage_charge10) { create(:usage_charge, plan: plan4, usage_lower: 0, usage_upper: nil, unit_price: 28.8) } + + context 'アンペア10A、使用量120kWhの場合' do + let(:ampere) { 10 } + let(:usage) { 120 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 2671 }, + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 3887 }, + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, + ]) + end + end + + context 'アンペア30A、使用量120kWhの場合' do + let(:ampere) { 30 } + let(:usage) { 120 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3243 }, + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4511 }, + { provider_name: '東京ガス', plan_name: 'ずっとも電気1', price: 3698 }, + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, + ]) + end + end + + + context 'アンペア10A、使用量121kWhの場合' do + let(:ampere) { 10 } + let(:usage) { 121 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3490 }, + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4716}, + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3484 }, + ]) + end + end + + context 'アンペア10A、使用量302kWhの場合' do + let(:ampere) { 10 } + let(:usage) { 302 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 9518 }, + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 12539 }, + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 8697}, + ]) + end + end + + context '該当するアンペアがない場合' do + let(:ampere) { 5 } + let(:usage) { 120 } + + it 'returns an empty array' do + is_expected.to be_empty + end + end + + context '該当する使用料がない場合' do + let(:ampere) { 30 } + let(:usage) { -1} + + it 'returns an empty array' do + is_expected.to be_empty + end + end + + describe '異常系' do + context 'アンペアがnilの場合' do + let(:ampere) { nil } + let(:usage) { 120 } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context '使用量がnilの場合' do + let(:ampere) { 30 } + let(:usage) { nil } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'アンペアがFloatの場合' do + let(:ampere) { 30.5 } + let(:usage) { 120 } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context '使用量がFloatの場合' do + let(:ampere) { 30 } + let(:usage) { 120.5 } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + end + end + + describe '.calc_price' do + subject { described_class.calc_price(basic_fee:, usage_charge:, usage:) } + + context '計算結果の小数点が0.5以上の場合' do + let(:basic_fee) { create(:basic_fee, fee: 311.75) } + let(:usage_charge) { create(:usage_charge, unit_price: 40.49) } + let(:usage) { 351 } + + it '計算結果が正しいこと。小数点以下切り捨てであること' do + expect(subject).to eq(14523) + end + end + + context '計算結果の小数点が0.5未満の場合' do + let(:basic_fee) { create(:basic_fee, fee: 100.01) } + let(:usage_charge) { create(:usage_charge, unit_price: 10.01) } + let(:usage) { 10 } + + it '計算結果が正しいこと。小数点以下切り捨てであること' do + expect(subject).to eq(200) + end + end + end end # == Schema Information diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index df3b59b06..85da5cb5b 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -8,7 +8,7 @@ describe 'scopes' do describe '.by_usage' do subject { described_class.by_usage(usage) } - + let(:usage) { 100 } let!(:usage_charge_1) { create(:usage_charge, usage_lower: 0, usage_upper: 100) } @@ -24,6 +24,17 @@ end end end + + describe '#calc_charge' do + subject { usage_charge.calc_charge(usage) } + + let(:usage_charge) { build(:usage_charge, unit_price: 19.88) } + let(:usage) { 111 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(2206.68) + end + end end # == Schema Information From ffcb7e76b3a1606df1bb5fa43ebbfa48a4d0e14f Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 20:29:05 +0900 Subject: [PATCH 09/37] =?UTF-8?q?GET=20/plan/prices=20=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controllers/plan_controller.rb | 36 ++++++ .../challenge/app/models/plan.rb | 2 +- .../challenge/config/routes.rb | 6 + .../challenge/spec/requests/plan_spec.rb | 106 ++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 serverside_challenge_2/challenge/app/controllers/plan_controller.rb create mode 100644 serverside_challenge_2/challenge/spec/requests/plan_spec.rb diff --git a/serverside_challenge_2/challenge/app/controllers/plan_controller.rb b/serverside_challenge_2/challenge/app/controllers/plan_controller.rb new file mode 100644 index 000000000..621806dfb --- /dev/null +++ b/serverside_challenge_2/challenge/app/controllers/plan_controller.rb @@ -0,0 +1,36 @@ +class PlanController < ApplicationController + PERMITTED_AMPERES = [10, 15, 20, 30, 40, 50, 60].freeze + + # return json : [{ provider_name: ‘Looopでんき’, plan_name: ‘おうちプラン’, price: ‘1234’ }, …] + def prices + validate_prices_params + + ampere = params[:ampere].to_i + usage = params[:usage].to_i + hash = Plan.plan_prices(ampere:, usage:) + render json: hash, status: :ok + rescue StandardError => e + render json: { error: e.message }, status: :bad_request + end + + private + + def validate_prices_params + ampere = params[:ampere] + usage = params[:usage] + + unless ampere.present? && usage.present? + raise ArgumentError, 'Ampere and usage must be provided' + end + + # 契約アンペア数 : 10 / 15 / 20 / 30 / 40 / 50 / 60 のいずれかとする(単位A) + unless ampere.match?(/\A\d+\z/) && PERMITTED_AMPERES.include?(ampere.to_i) + raise ArgumentError, "Ampere must be one of #{PERMITTED_AMPERES.join(', ')}" + end + + # 使用量 : 0以上の整数(単位kWh) + unless usage.match?(/\A\d+\z/) && usage.to_i >= 0 + raise ArgumentError, 'Usage must be a non-negative integer' + end + end +end diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index a56fa9371..02691b43a 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -20,7 +20,6 @@ class Plan < ApplicationRecord # @param usage [Integer] 電気使用量(kWh) # @return [Array] [{ provider_name: ‘Looopでんき’, plan_name: ‘おうちプラン’, price: ‘1234’ }, …] def self.plan_prices(ampere:, usage:) - # Integerであること raise ArgumentError, 'ampere must be an Integer' unless ampere.is_a?(Integer) raise ArgumentError, 'usage must be an Integer' unless usage.is_a?(Integer) @@ -39,6 +38,7 @@ def self.plan_prices(ampere:, usage:) private + # 電気料金 = ①基本料金 + ②従量料金 # @param basic_fee [BasicFee] 基本料金オブジェクト # @param usage_charge [UsageCharge] 従量料金オブジェクト # @param usage [Integer] 電気使用量(kWh) diff --git a/serverside_challenge_2/challenge/config/routes.rb b/serverside_challenge_2/challenge/config/routes.rb index 262ffd547..4d94565e4 100644 --- a/serverside_challenge_2/challenge/config/routes.rb +++ b/serverside_challenge_2/challenge/config/routes.rb @@ -3,4 +3,10 @@ # Defines the root path route ("/") # root "articles#index" + + resources :plan, only: [] do + collection do + get :prices + end + end end diff --git a/serverside_challenge_2/challenge/spec/requests/plan_spec.rb b/serverside_challenge_2/challenge/spec/requests/plan_spec.rb new file mode 100644 index 000000000..25304d387 --- /dev/null +++ b/serverside_challenge_2/challenge/spec/requests/plan_spec.rb @@ -0,0 +1,106 @@ +require 'rails_helper' + +RSpec.describe "Plans", type: :request do + describe "GET /prices" do + subject { get '/plan/prices', params: } + + let(:provider1) { create(:provider, name: '東京電力エナジーパートナー') } + let(:plan1) { create(:plan, name: 'スタンダードS', provider: provider1) } + let!(:basic_fee1) { create(:basic_fee, plan: plan1, ampere: 10, fee: 311.75) } + let!(:usage_charge1) { create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 29.80) } + + let(:provider2) { create(:provider, name: 'Looopでんき') } + let(:plan2) { create(:plan, name: 'おうちプラン', provider: provider2) } + let!(:basic_fee2) { create(:basic_fee, plan: plan2, ampere: 10, fee: 0.00) } + let!(:usage_charge2) { create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: nil, unit_price: 28.8) } + + shared_examples 'bad request' do |expected_message| + it 'response bad_request and error message' do + subject + expect(response).to have_http_status(:bad_request) + expect(response.content_type).to eq('application/json; charset=utf-8') + json = JSON.parse(response.body) + expect(json).to be_a(Hash) + expect(json['error']).to include(expected_message) + end + end + + shared_examples 'ok' do |expected_array| + it 'response ok and valid json' do + subject + expect(response).to have_http_status(:ok) + expect(response.content_type).to eq('application/json; charset=utf-8') + json = JSON.parse(response.body) + expect(json).to be_an(Array) + expect(json).to match_array(expected_array) + end + end + + describe '正常系' do + context '' do + let(:params) { { ampere: 10, usage: 120 } } + + it_behaves_like 'ok', [ + { + 'provider_name' => '東京電力エナジーパートナー', + 'plan_name' => 'スタンダードS', + 'price' => 3887 + }, + { + 'provider_name' => 'Looopでんき', + 'plan_name' => 'おうちプラン', + 'price' => 3456 + } + ] + end + context 'usageが0の時(0以上バリデーションの境界値)' do + let(:params) { { ampere: 10, usage: 0 } } + + it_behaves_like 'ok', [ + { + 'provider_name' => '東京電力エナジーパートナー', + 'plan_name' => 'スタンダードS', + 'price' => 311 + }, + { + 'provider_name' => 'Looopでんき', + 'plan_name' => 'おうちプラン', + 'price' => 0 + } + ] + end + end + + describe 'validations' do + context 'ampere is missing' do + let(:params) { { usage: 100 } } + it_behaves_like 'bad request', 'Ampere and usage must be provided' + end + + context 'usage is missing' do + let(:params) { { ampere: 30 } } + it_behaves_like 'bad request', 'Ampere and usage must be provided' + end + + context 'ampere is not permitted' do + let(:params) { { ampere: 99, usage: 100 } } + it_behaves_like 'bad request', 'Ampere must be one of' + end + + context 'usage is negative' do + let(:params) { { ampere: 30, usage: -1 } } + it_behaves_like 'bad request', 'Usage must be a non-negative integer' + end + + context 'ampere is float' do + let(:params) { { ampere: 30.5, usage: 100 } } + it_behaves_like 'bad request', 'Ampere must be one of' + end + + context 'usage is float' do + let(:params) { { ampere: 30, usage: 100.5 } } + it_behaves_like 'bad request', 'Usage must be a non-negative integer' + end + end + end +end From fbf1658c38ad9e2a590120a8b3eef4604cddbbc7 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sun, 28 Sep 2025 20:32:35 +0900 Subject: [PATCH 10/37] Fix context description --- serverside_challenge_2/challenge/spec/requests/plan_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/spec/requests/plan_spec.rb b/serverside_challenge_2/challenge/spec/requests/plan_spec.rb index 25304d387..945308d76 100644 --- a/serverside_challenge_2/challenge/spec/requests/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/requests/plan_spec.rb @@ -37,7 +37,7 @@ end describe '正常系' do - context '' do + context 'typical case' do let(:params) { { ampere: 10, usage: 120 } } it_behaves_like 'ok', [ @@ -53,6 +53,7 @@ } ] end + context 'usageが0の時(0以上バリデーションの境界値)' do let(:params) { { ampere: 10, usage: 0 } } From 1432e4b1d3eddb738e6871f8bc8a6d6b95e9d5bb Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 29 Sep 2025 22:22:15 +0900 Subject: [PATCH 11/37] =?UTF-8?q?view=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serverside_challenge_2/challenge/Dockerfile | 9 ++-- serverside_challenge_2/challenge/Gemfile | 4 ++ serverside_challenge_2/challenge/Gemfile.lock | 13 ++++++ .../app/controllers/application_controller.rb | 2 +- ...plan_controller.rb => plans_controller.rb} | 4 +- .../challenge/app/javascript/application.js | 2 + .../app/javascript/controllers/application.js | 9 ++++ .../app/javascript/controllers/index.js | 4 ++ .../controllers/plans_controller.js | 45 +++++++++++++++++++ .../app/views/layouts/application.html.erb | 11 +++++ .../challenge/app/views/plans/index.html.erb | 27 +++++++++++ .../challenge/bin/importmap | 4 ++ .../challenge/config/application.rb | 2 +- .../challenge/config/importmap.rb | 7 +++ .../challenge/config/routes.rb | 2 +- .../requests/{plan_spec.rb => plans_spec.rb} | 4 +- .../challenge/vendor/javascript/.keep | 0 .../vendor/javascript/@hotwired--stimulus.js | 4 ++ 18 files changed, 143 insertions(+), 10 deletions(-) rename serverside_challenge_2/challenge/app/controllers/{plan_controller.rb => plans_controller.rb} (94%) create mode 100644 serverside_challenge_2/challenge/app/javascript/application.js create mode 100644 serverside_challenge_2/challenge/app/javascript/controllers/application.js create mode 100644 serverside_challenge_2/challenge/app/javascript/controllers/index.js create mode 100644 serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js create mode 100644 serverside_challenge_2/challenge/app/views/layouts/application.html.erb create mode 100644 serverside_challenge_2/challenge/app/views/plans/index.html.erb create mode 100755 serverside_challenge_2/challenge/bin/importmap create mode 100644 serverside_challenge_2/challenge/config/importmap.rb rename serverside_challenge_2/challenge/spec/requests/{plan_spec.rb => plans_spec.rb} (97%) create mode 100644 serverside_challenge_2/challenge/vendor/javascript/.keep create mode 100644 serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js diff --git a/serverside_challenge_2/challenge/Dockerfile b/serverside_challenge_2/challenge/Dockerfile index 90702f517..ceeea154e 100644 --- a/serverside_challenge_2/challenge/Dockerfile +++ b/serverside_challenge_2/challenge/Dockerfile @@ -2,10 +2,11 @@ FROM ruby:3.1.2 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim RUN useradd -ms /bin/bash rails -RUN mkdir /app && chown -R rails:rails /app +RUN mkdir /app +# Gemfile をコピー(権限を rails に変更) +COPY --chown=rails:rails Gemfile Gemfile.lock /app/ USER rails WORKDIR /app -ADD Gemfile /app/Gemfile -ADD Gemfile.lock /app/Gemfile.lock RUN bundle install -ADD . /app +# アプリ本体をコピー(rails ユーザーに所有権) +COPY --chown=rails:rails . /app \ No newline at end of file diff --git a/serverside_challenge_2/challenge/Gemfile b/serverside_challenge_2/challenge/Gemfile index 3d6fabe41..25cc2e630 100644 --- a/serverside_challenge_2/challenge/Gemfile +++ b/serverside_challenge_2/challenge/Gemfile @@ -36,6 +36,10 @@ gem "bootsnap", require: false # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible # gem "rack-cors" +gem "importmap-rails" +gem "stimulus-rails" +gem "propshaft" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri mingw x64_mingw ] diff --git a/serverside_challenge_2/challenge/Gemfile.lock b/serverside_challenge_2/challenge/Gemfile.lock index 54d2d315c..cedc746b8 100644 --- a/serverside_challenge_2/challenge/Gemfile.lock +++ b/serverside_challenge_2/challenge/Gemfile.lock @@ -89,6 +89,10 @@ GEM activesupport (>= 6.1) i18n (1.14.1) concurrent-ruby (~> 1.0) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) io-console (0.7.2) irb (1.11.2) rdoc @@ -121,6 +125,10 @@ GEM nokogiri (1.16.2-x86_64-linux) racc (~> 1.4) pg (1.5.4) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack psych (5.1.2) stringio puma (5.6.8) @@ -181,6 +189,8 @@ GEM rspec-support (3.13.6) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) + stimulus-rails (1.3.4) + railties (>= 6.0.0) stringio (3.1.0) thor (1.3.0) timeout (0.4.1) @@ -200,11 +210,14 @@ DEPENDENCIES bootsnap debug factory_bot_rails + importmap-rails pg (~> 1.1) + propshaft puma (~> 5.0) rails (~> 7.0.8) rspec-rails (~> 6.0) shoulda-matchers (~> 5.0) + stimulus-rails tzinfo-data RUBY VERSION diff --git a/serverside_challenge_2/challenge/app/controllers/application_controller.rb b/serverside_challenge_2/challenge/app/controllers/application_controller.rb index 4ac8823b0..09705d12a 100644 --- a/serverside_challenge_2/challenge/app/controllers/application_controller.rb +++ b/serverside_challenge_2/challenge/app/controllers/application_controller.rb @@ -1,2 +1,2 @@ -class ApplicationController < ActionController::API +class ApplicationController < ActionController::Base end diff --git a/serverside_challenge_2/challenge/app/controllers/plan_controller.rb b/serverside_challenge_2/challenge/app/controllers/plans_controller.rb similarity index 94% rename from serverside_challenge_2/challenge/app/controllers/plan_controller.rb rename to serverside_challenge_2/challenge/app/controllers/plans_controller.rb index 621806dfb..38de51d4d 100644 --- a/serverside_challenge_2/challenge/app/controllers/plan_controller.rb +++ b/serverside_challenge_2/challenge/app/controllers/plans_controller.rb @@ -1,6 +1,8 @@ -class PlanController < ApplicationController +class PlansController < ApplicationController PERMITTED_AMPERES = [10, 15, 20, 30, 40, 50, 60].freeze + def index; end + # return json : [{ provider_name: ‘Looopでんき’, plan_name: ‘おうちプラン’, price: ‘1234’ }, …] def prices validate_prices_params diff --git a/serverside_challenge_2/challenge/app/javascript/application.js b/serverside_challenge_2/challenge/app/javascript/application.js new file mode 100644 index 000000000..c275ea839 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/application.js @@ -0,0 +1,2 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "controllers" diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/application.js b/serverside_challenge_2/challenge/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/index.js b/serverside_challenge_2/challenge/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js new file mode 100644 index 000000000..de7b8851f --- /dev/null +++ b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js @@ -0,0 +1,45 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["ampere", "usage", "results"] + + connect() { + console.log("PlansController connected"); + } + + async search(event) { + event.preventDefault(); + + const ampere = this.ampereTarget.value; + const usage = this.usageTarget.value; + this.resultsTarget.innerHTML = ""; + + try { + const response = await fetch(`/plans/prices?ampere=${ampere}&usage=${usage}`); + console.log(response); + if (!response.ok) throw new Error("API request failed"); + const data = await response.json(); + if (Array.isArray(data) && data.length > 0) { + console.log(data); + data.forEach(plan => { + const row = this.createRow(plan); + this.resultsTarget.appendChild(row); + }); + } else { + alert("該当するプランがありません。"); + } + } catch (error) { + alert("検索に失敗しました: " + error.message); + } + } + + createRow(object) { + const row = document.createElement("tr"); + row.innerHTML = ` + ${object.provider_name} + ${object.plan_name} + ${object.price} + `; + return row; + } +} diff --git a/serverside_challenge_2/challenge/app/views/layouts/application.html.erb b/serverside_challenge_2/challenge/app/views/layouts/application.html.erb new file mode 100644 index 000000000..4ea9beb4f --- /dev/null +++ b/serverside_challenge_2/challenge/app/views/layouts/application.html.erb @@ -0,0 +1,11 @@ + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= javascript_importmap_tags %> + + + <%= yield %> + + diff --git a/serverside_challenge_2/challenge/app/views/plans/index.html.erb b/serverside_challenge_2/challenge/app/views/plans/index.html.erb new file mode 100644 index 000000000..a8ef9341f --- /dev/null +++ b/serverside_challenge_2/challenge/app/views/plans/index.html.erb @@ -0,0 +1,27 @@ +

電気料金のシミュレーション

+ +
+
+ + <%= select_tag :ampere, options_for_select(PlansController::PERMITTED_AMPERES), data: { plans_target: "ampere" } %> +
+ + +
+ +
+ + + + + + + + + + + +
電力会社名プラン名電気料金(円)
+
diff --git a/serverside_challenge_2/challenge/bin/importmap b/serverside_challenge_2/challenge/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/serverside_challenge_2/challenge/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/serverside_challenge_2/challenge/config/application.rb b/serverside_challenge_2/challenge/config/application.rb index b39913bf7..dba5bb221 100644 --- a/serverside_challenge_2/challenge/config/application.rb +++ b/serverside_challenge_2/challenge/config/application.rb @@ -22,6 +22,6 @@ class Application < Rails::Application # Only loads a smaller set of middleware suitable for API only apps. # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. - config.api_only = true + config.api_only = false end end diff --git a/serverside_challenge_2/challenge/config/importmap.rb b/serverside_challenge_2/challenge/config/importmap.rb new file mode 100644 index 000000000..5013c58b6 --- /dev/null +++ b/serverside_challenge_2/challenge/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2 +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" + diff --git a/serverside_challenge_2/challenge/config/routes.rb b/serverside_challenge_2/challenge/config/routes.rb index 4d94565e4..525948ecc 100644 --- a/serverside_challenge_2/challenge/config/routes.rb +++ b/serverside_challenge_2/challenge/config/routes.rb @@ -4,7 +4,7 @@ # Defines the root path route ("/") # root "articles#index" - resources :plan, only: [] do + resources :plans, only: [:index] do collection do get :prices end diff --git a/serverside_challenge_2/challenge/spec/requests/plan_spec.rb b/serverside_challenge_2/challenge/spec/requests/plans_spec.rb similarity index 97% rename from serverside_challenge_2/challenge/spec/requests/plan_spec.rb rename to serverside_challenge_2/challenge/spec/requests/plans_spec.rb index 945308d76..f4cc33b6e 100644 --- a/serverside_challenge_2/challenge/spec/requests/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/requests/plans_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' RSpec.describe "Plans", type: :request do - describe "GET /prices" do - subject { get '/plan/prices', params: } + describe "GET /plans/prices" do + subject { get '/plans/prices', params: } let(:provider1) { create(:provider, name: '東京電力エナジーパートナー') } let(:plan1) { create(:plan, name: 'スタンダードS', provider: provider1) } diff --git a/serverside_challenge_2/challenge/vendor/javascript/.keep b/serverside_challenge_2/challenge/vendor/javascript/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js b/serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js new file mode 100644 index 000000000..07dd4a6cc --- /dev/null +++ b/serverside_challenge_2/challenge/vendor/javascript/@hotwired--stimulus.js @@ -0,0 +1,4 @@ +// @hotwired/stimulus@3.2.2 downloaded from https://ga.jspm.io/npm:@hotwired/stimulus@3.2.2/dist/stimulus.js + +class EventListener{constructor(e,t,r){this.eventTarget=e;this.eventName=t;this.eventOptions=r;this.unorderedBindings=new Set}connect(){this.eventTarget.addEventListener(this.eventName,this,this.eventOptions)}disconnect(){this.eventTarget.removeEventListener(this.eventName,this,this.eventOptions)}bindingConnected(e){this.unorderedBindings.add(e)}bindingDisconnected(e){this.unorderedBindings.delete(e)}handleEvent(e){const t=extendEvent(e);for(const e of this.bindings){if(t.immediatePropagationStopped)break;e.handleEvent(t)}}hasBindings(){return this.unorderedBindings.size>0}get bindings(){return Array.from(this.unorderedBindings).sort(((e,t)=>{const r=e.index,s=t.index;return rs?1:0}))}}function extendEvent(e){if("immediatePropagationStopped"in e)return e;{const{stopImmediatePropagation:t}=e;return Object.assign(e,{immediatePropagationStopped:false,stopImmediatePropagation(){this.immediatePropagationStopped=true;t.call(this)}})}}class Dispatcher{constructor(e){this.application=e;this.eventListenerMaps=new Map;this.started=false}start(){if(!this.started){this.started=true;this.eventListeners.forEach((e=>e.connect()))}}stop(){if(this.started){this.started=false;this.eventListeners.forEach((e=>e.disconnect()))}}get eventListeners(){return Array.from(this.eventListenerMaps.values()).reduce(((e,t)=>e.concat(Array.from(t.values()))),[])}bindingConnected(e){this.fetchEventListenerForBinding(e).bindingConnected(e)}bindingDisconnected(e,t=false){this.fetchEventListenerForBinding(e).bindingDisconnected(e);t&&this.clearEventListenersForBinding(e)}handleError(e,t,r={}){this.application.handleError(e,`Error ${t}`,r)}clearEventListenersForBinding(e){const t=this.fetchEventListenerForBinding(e);if(!t.hasBindings()){t.disconnect();this.removeMappedEventListenerFor(e)}}removeMappedEventListenerFor(e){const{eventTarget:t,eventName:r,eventOptions:s}=e;const n=this.fetchEventListenerMapForEventTarget(t);const i=this.cacheKey(r,s);n.delete(i);0==n.size&&this.eventListenerMaps.delete(t)}fetchEventListenerForBinding(e){const{eventTarget:t,eventName:r,eventOptions:s}=e;return this.fetchEventListener(t,r,s)}fetchEventListener(e,t,r){const s=this.fetchEventListenerMapForEventTarget(e);const n=this.cacheKey(t,r);let i=s.get(n);if(!i){i=this.createEventListener(e,t,r);s.set(n,i)}return i}createEventListener(e,t,r){const s=new EventListener(e,t,r);this.started&&s.connect();return s}fetchEventListenerMapForEventTarget(e){let t=this.eventListenerMaps.get(e);if(!t){t=new Map;this.eventListenerMaps.set(e,t)}return t}cacheKey(e,t){const r=[e];Object.keys(t).sort().forEach((e=>{r.push(`${t[e]?"":"!"}${e}`)}));return r.join(":")}}const e={stop({event:e,value:t}){t&&e.stopPropagation();return true},prevent({event:e,value:t}){t&&e.preventDefault();return true},self({event:e,value:t,element:r}){return!t||r===e.target}};const t=/^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/;function parseActionDescriptorString(e){const r=e.trim();const s=r.match(t)||[];let n=s[2];let i=s[3];if(i&&!["keydown","keyup","keypress"].includes(n)){n+=`.${i}`;i=""}return{eventTarget:parseEventTarget(s[4]),eventName:n,eventOptions:s[7]?parseEventOptions(s[7]):{},identifier:s[5],methodName:s[6],keyFilter:s[1]||i}}function parseEventTarget(e){return"window"==e?window:"document"==e?document:void 0}function parseEventOptions(e){return e.split(":").reduce(((e,t)=>Object.assign(e,{[t.replace(/^!/,"")]:!/^!/.test(t)})),{})}function stringifyEventTarget(e){return e==window?"window":e==document?"document":void 0}function camelize(e){return e.replace(/(?:[_-])([a-z0-9])/g,((e,t)=>t.toUpperCase()))}function namespaceCamelize(e){return camelize(e.replace(/--/g,"-").replace(/__/g,"_"))}function capitalize(e){return e.charAt(0).toUpperCase()+e.slice(1)}function dasherize(e){return e.replace(/([A-Z])/g,((e,t)=>`-${t.toLowerCase()}`))}function tokenize(e){return e.match(/[^\s]+/g)||[]}function isSomething(e){return null!==e&&void 0!==e}function hasProperty(e,t){return Object.prototype.hasOwnProperty.call(e,t)}const r=["meta","ctrl","alt","shift"];class Action{constructor(e,t,r,s){this.element=e;this.index=t;this.eventTarget=r.eventTarget||e;this.eventName=r.eventName||getDefaultEventNameForElement(e)||error("missing event name");this.eventOptions=r.eventOptions||{};this.identifier=r.identifier||error("missing identifier");this.methodName=r.methodName||error("missing method name");this.keyFilter=r.keyFilter||"";this.schema=s}static forToken(e,t){return new this(e.element,e.index,parseActionDescriptorString(e.content),t)}toString(){const e=this.keyFilter?`.${this.keyFilter}`:"";const t=this.eventTargetName?`@${this.eventTargetName}`:"";return`${this.eventName}${e}${t}->${this.identifier}#${this.methodName}`}shouldIgnoreKeyboardEvent(e){if(!this.keyFilter)return false;const t=this.keyFilter.split("+");if(this.keyFilterDissatisfied(e,t))return true;const s=t.filter((e=>!r.includes(e)))[0];if(!s)return false;hasProperty(this.keyMappings,s)||error(`contains unknown key filter: ${this.keyFilter}`);return this.keyMappings[s].toLowerCase()!==e.key.toLowerCase()}shouldIgnoreMouseEvent(e){if(!this.keyFilter)return false;const t=[this.keyFilter];return!!this.keyFilterDissatisfied(e,t)}get params(){const e={};const t=new RegExp(`^data-${this.identifier}-(.+)-param$`,"i");for(const{name:r,value:s}of Array.from(this.element.attributes)){const n=r.match(t);const i=n&&n[1];i&&(e[camelize(i)]=typecast(s))}return e}get eventTargetName(){return stringifyEventTarget(this.eventTarget)}get keyMappings(){return this.schema.keyMappings}keyFilterDissatisfied(e,t){const[s,n,i,o]=r.map((e=>t.includes(e)));return e.metaKey!==s||e.ctrlKey!==n||e.altKey!==i||e.shiftKey!==o}}const s={a:()=>"click",button:()=>"click",form:()=>"submit",details:()=>"toggle",input:e=>"submit"==e.getAttribute("type")?"click":"input",select:()=>"change",textarea:()=>"input"};function getDefaultEventNameForElement(e){const t=e.tagName.toLowerCase();if(t in s)return s[t](e)}function error(e){throw new Error(e)}function typecast(e){try{return JSON.parse(e)}catch(t){return e}}class Binding{constructor(e,t){this.context=e;this.action=t}get index(){return this.action.index}get eventTarget(){return this.action.eventTarget}get eventOptions(){return this.action.eventOptions}get identifier(){return this.context.identifier}handleEvent(e){const t=this.prepareActionEvent(e);this.willBeInvokedByEvent(e)&&this.applyEventModifiers(t)&&this.invokeWithEvent(t)}get eventName(){return this.action.eventName}get method(){const e=this.controller[this.methodName];if("function"==typeof e)return e;throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`)}applyEventModifiers(e){const{element:t}=this.action;const{actionDescriptorFilters:r}=this.context.application;const{controller:s}=this.context;let n=true;for(const[i,o]of Object.entries(this.eventOptions))if(i in r){const c=r[i];n=n&&c({name:i,value:o,event:e,element:t,controller:s})}return n}prepareActionEvent(e){return Object.assign(e,{params:this.action.params})}invokeWithEvent(e){const{target:t,currentTarget:r}=e;try{this.method.call(this.controller,e);this.context.logDebugActivity(this.methodName,{event:e,target:t,currentTarget:r,action:this.methodName})}catch(t){const{identifier:r,controller:s,element:n,index:i}=this;const o={identifier:r,controller:s,element:n,index:i,event:e};this.context.handleError(t,`invoking action "${this.action}"`,o)}}willBeInvokedByEvent(e){const t=e.target;return!(e instanceof KeyboardEvent&&this.action.shouldIgnoreKeyboardEvent(e))&&(!(e instanceof MouseEvent&&this.action.shouldIgnoreMouseEvent(e))&&(this.element===t||(t instanceof Element&&this.element.contains(t)?this.scope.containsElement(t):this.scope.containsElement(this.action.element))))}get controller(){return this.context.controller}get methodName(){return this.action.methodName}get element(){return this.scope.element}get scope(){return this.context.scope}}class ElementObserver{constructor(e,t){this.mutationObserverInit={attributes:true,childList:true,subtree:true};this.element=e;this.started=false;this.delegate=t;this.elements=new Set;this.mutationObserver=new MutationObserver((e=>this.processMutations(e)))}start(){if(!this.started){this.started=true;this.mutationObserver.observe(this.element,this.mutationObserverInit);this.refresh()}}pause(e){if(this.started){this.mutationObserver.disconnect();this.started=false}e();if(!this.started){this.mutationObserver.observe(this.element,this.mutationObserverInit);this.started=true}}stop(){if(this.started){this.mutationObserver.takeRecords();this.mutationObserver.disconnect();this.started=false}}refresh(){if(this.started){const e=new Set(this.matchElementsInTree());for(const t of Array.from(this.elements))e.has(t)||this.removeElement(t);for(const t of Array.from(e))this.addElement(t)}}processMutations(e){if(this.started)for(const t of e)this.processMutation(t)}processMutation(e){if("attributes"==e.type)this.processAttributeChange(e.target,e.attributeName);else if("childList"==e.type){this.processRemovedNodes(e.removedNodes);this.processAddedNodes(e.addedNodes)}}processAttributeChange(e,t){this.elements.has(e)?this.delegate.elementAttributeChanged&&this.matchElement(e)?this.delegate.elementAttributeChanged(e,t):this.removeElement(e):this.matchElement(e)&&this.addElement(e)}processRemovedNodes(e){for(const t of Array.from(e)){const e=this.elementFromNode(t);e&&this.processTree(e,this.removeElement)}}processAddedNodes(e){for(const t of Array.from(e)){const e=this.elementFromNode(t);e&&this.elementIsActive(e)&&this.processTree(e,this.addElement)}}matchElement(e){return this.delegate.matchElement(e)}matchElementsInTree(e=this.element){return this.delegate.matchElementsInTree(e)}processTree(e,t){for(const r of this.matchElementsInTree(e))t.call(this,r)}elementFromNode(e){if(e.nodeType==Node.ELEMENT_NODE)return e}elementIsActive(e){return e.isConnected==this.element.isConnected&&this.element.contains(e)}addElement(e){if(!this.elements.has(e)&&this.elementIsActive(e)){this.elements.add(e);this.delegate.elementMatched&&this.delegate.elementMatched(e)}}removeElement(e){if(this.elements.has(e)){this.elements.delete(e);this.delegate.elementUnmatched&&this.delegate.elementUnmatched(e)}}}class AttributeObserver{constructor(e,t,r){this.attributeName=t;this.delegate=r;this.elementObserver=new ElementObserver(e,this)}get element(){return this.elementObserver.element}get selector(){return`[${this.attributeName}]`}start(){this.elementObserver.start()}pause(e){this.elementObserver.pause(e)}stop(){this.elementObserver.stop()}refresh(){this.elementObserver.refresh()}get started(){return this.elementObserver.started}matchElement(e){return e.hasAttribute(this.attributeName)}matchElementsInTree(e){const t=this.matchElement(e)?[e]:[];const r=Array.from(e.querySelectorAll(this.selector));return t.concat(r)}elementMatched(e){this.delegate.elementMatchedAttribute&&this.delegate.elementMatchedAttribute(e,this.attributeName)}elementUnmatched(e){this.delegate.elementUnmatchedAttribute&&this.delegate.elementUnmatchedAttribute(e,this.attributeName)}elementAttributeChanged(e,t){this.delegate.elementAttributeValueChanged&&this.attributeName==t&&this.delegate.elementAttributeValueChanged(e,t)}}function add(e,t,r){fetch(e,t).add(r)}function del(e,t,r){fetch(e,t).delete(r);prune(e,t)}function fetch(e,t){let r=e.get(t);if(!r){r=new Set;e.set(t,r)}return r}function prune(e,t){const r=e.get(t);null!=r&&0==r.size&&e.delete(t)}class Multimap{constructor(){this.valuesByKey=new Map}get keys(){return Array.from(this.valuesByKey.keys())}get values(){const e=Array.from(this.valuesByKey.values());return e.reduce(((e,t)=>e.concat(Array.from(t))),[])}get size(){const e=Array.from(this.valuesByKey.values());return e.reduce(((e,t)=>e+t.size),0)}add(e,t){add(this.valuesByKey,e,t)}delete(e,t){del(this.valuesByKey,e,t)}has(e,t){const r=this.valuesByKey.get(e);return null!=r&&r.has(t)}hasKey(e){return this.valuesByKey.has(e)}hasValue(e){const t=Array.from(this.valuesByKey.values());return t.some((t=>t.has(e)))}getValuesForKey(e){const t=this.valuesByKey.get(e);return t?Array.from(t):[]}getKeysForValue(e){return Array.from(this.valuesByKey).filter((([t,r])=>r.has(e))).map((([e,t])=>e))}}class IndexedMultimap extends Multimap{constructor(){super();this.keysByValue=new Map}get values(){return Array.from(this.keysByValue.keys())}add(e,t){super.add(e,t);add(this.keysByValue,t,e)}delete(e,t){super.delete(e,t);del(this.keysByValue,t,e)}hasValue(e){return this.keysByValue.has(e)}getKeysForValue(e){const t=this.keysByValue.get(e);return t?Array.from(t):[]}}class SelectorObserver{constructor(e,t,r,s){this._selector=t;this.details=s;this.elementObserver=new ElementObserver(e,this);this.delegate=r;this.matchesByElement=new Multimap}get started(){return this.elementObserver.started}get selector(){return this._selector}set selector(e){this._selector=e;this.refresh()}start(){this.elementObserver.start()}pause(e){this.elementObserver.pause(e)}stop(){this.elementObserver.stop()}refresh(){this.elementObserver.refresh()}get element(){return this.elementObserver.element}matchElement(e){const{selector:t}=this;if(t){const r=e.matches(t);return this.delegate.selectorMatchElement?r&&this.delegate.selectorMatchElement(e,this.details):r}return false}matchElementsInTree(e){const{selector:t}=this;if(t){const r=this.matchElement(e)?[e]:[];const s=Array.from(e.querySelectorAll(t)).filter((e=>this.matchElement(e)));return r.concat(s)}return[]}elementMatched(e){const{selector:t}=this;t&&this.selectorMatched(e,t)}elementUnmatched(e){const t=this.matchesByElement.getKeysForValue(e);for(const r of t)this.selectorUnmatched(e,r)}elementAttributeChanged(e,t){const{selector:r}=this;if(r){const t=this.matchElement(e);const s=this.matchesByElement.has(r,e);t&&!s?this.selectorMatched(e,r):!t&&s&&this.selectorUnmatched(e,r)}}selectorMatched(e,t){this.delegate.selectorMatched(e,t,this.details);this.matchesByElement.add(t,e)}selectorUnmatched(e,t){this.delegate.selectorUnmatched(e,t,this.details);this.matchesByElement.delete(t,e)}}class StringMapObserver{constructor(e,t){this.element=e;this.delegate=t;this.started=false;this.stringMap=new Map;this.mutationObserver=new MutationObserver((e=>this.processMutations(e)))}start(){if(!this.started){this.started=true;this.mutationObserver.observe(this.element,{attributes:true,attributeOldValue:true});this.refresh()}}stop(){if(this.started){this.mutationObserver.takeRecords();this.mutationObserver.disconnect();this.started=false}}refresh(){if(this.started)for(const e of this.knownAttributeNames)this.refreshAttribute(e,null)}processMutations(e){if(this.started)for(const t of e)this.processMutation(t)}processMutation(e){const t=e.attributeName;t&&this.refreshAttribute(t,e.oldValue)}refreshAttribute(e,t){const r=this.delegate.getStringMapKeyForAttribute(e);if(null!=r){this.stringMap.has(e)||this.stringMapKeyAdded(r,e);const s=this.element.getAttribute(e);this.stringMap.get(e)!=s&&this.stringMapValueChanged(s,r,t);if(null==s){const t=this.stringMap.get(e);this.stringMap.delete(e);t&&this.stringMapKeyRemoved(r,e,t)}else this.stringMap.set(e,s)}}stringMapKeyAdded(e,t){this.delegate.stringMapKeyAdded&&this.delegate.stringMapKeyAdded(e,t)}stringMapValueChanged(e,t,r){this.delegate.stringMapValueChanged&&this.delegate.stringMapValueChanged(e,t,r)}stringMapKeyRemoved(e,t,r){this.delegate.stringMapKeyRemoved&&this.delegate.stringMapKeyRemoved(e,t,r)}get knownAttributeNames(){return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames)))}get currentAttributeNames(){return Array.from(this.element.attributes).map((e=>e.name))}get recordedAttributeNames(){return Array.from(this.stringMap.keys())}}class TokenListObserver{constructor(e,t,r){this.attributeObserver=new AttributeObserver(e,t,this);this.delegate=r;this.tokensByElement=new Multimap}get started(){return this.attributeObserver.started}start(){this.attributeObserver.start()}pause(e){this.attributeObserver.pause(e)}stop(){this.attributeObserver.stop()}refresh(){this.attributeObserver.refresh()}get element(){return this.attributeObserver.element}get attributeName(){return this.attributeObserver.attributeName}elementMatchedAttribute(e){this.tokensMatched(this.readTokensForElement(e))}elementAttributeValueChanged(e){const[t,r]=this.refreshTokensForElement(e);this.tokensUnmatched(t);this.tokensMatched(r)}elementUnmatchedAttribute(e){this.tokensUnmatched(this.tokensByElement.getValuesForKey(e))}tokensMatched(e){e.forEach((e=>this.tokenMatched(e)))}tokensUnmatched(e){e.forEach((e=>this.tokenUnmatched(e)))}tokenMatched(e){this.delegate.tokenMatched(e);this.tokensByElement.add(e.element,e)}tokenUnmatched(e){this.delegate.tokenUnmatched(e);this.tokensByElement.delete(e.element,e)}refreshTokensForElement(e){const t=this.tokensByElement.getValuesForKey(e);const r=this.readTokensForElement(e);const s=zip(t,r).findIndex((([e,t])=>!tokensAreEqual(e,t)));return-1==s?[[],[]]:[t.slice(s),r.slice(s)]}readTokensForElement(e){const t=this.attributeName;const r=e.getAttribute(t)||"";return parseTokenString(r,e,t)}}function parseTokenString(e,t,r){return e.trim().split(/\s+/).filter((e=>e.length)).map(((e,s)=>({element:t,attributeName:r,content:e,index:s})))}function zip(e,t){const r=Math.max(e.length,t.length);return Array.from({length:r},((r,s)=>[e[s],t[s]]))}function tokensAreEqual(e,t){return e&&t&&e.index==t.index&&e.content==t.content}class ValueListObserver{constructor(e,t,r){this.tokenListObserver=new TokenListObserver(e,t,this);this.delegate=r;this.parseResultsByToken=new WeakMap;this.valuesByTokenByElement=new WeakMap}get started(){return this.tokenListObserver.started}start(){this.tokenListObserver.start()}stop(){this.tokenListObserver.stop()}refresh(){this.tokenListObserver.refresh()}get element(){return this.tokenListObserver.element}get attributeName(){return this.tokenListObserver.attributeName}tokenMatched(e){const{element:t}=e;const{value:r}=this.fetchParseResultForToken(e);if(r){this.fetchValuesByTokenForElement(t).set(e,r);this.delegate.elementMatchedValue(t,r)}}tokenUnmatched(e){const{element:t}=e;const{value:r}=this.fetchParseResultForToken(e);if(r){this.fetchValuesByTokenForElement(t).delete(e);this.delegate.elementUnmatchedValue(t,r)}}fetchParseResultForToken(e){let t=this.parseResultsByToken.get(e);if(!t){t=this.parseToken(e);this.parseResultsByToken.set(e,t)}return t}fetchValuesByTokenForElement(e){let t=this.valuesByTokenByElement.get(e);if(!t){t=new Map;this.valuesByTokenByElement.set(e,t)}return t}parseToken(e){try{const t=this.delegate.parseValueForToken(e);return{value:t}}catch(e){return{error:e}}}}class BindingObserver{constructor(e,t){this.context=e;this.delegate=t;this.bindingsByAction=new Map}start(){if(!this.valueListObserver){this.valueListObserver=new ValueListObserver(this.element,this.actionAttribute,this);this.valueListObserver.start()}}stop(){if(this.valueListObserver){this.valueListObserver.stop();delete this.valueListObserver;this.disconnectAllActions()}}get element(){return this.context.element}get identifier(){return this.context.identifier}get actionAttribute(){return this.schema.actionAttribute}get schema(){return this.context.schema}get bindings(){return Array.from(this.bindingsByAction.values())}connectAction(e){const t=new Binding(this.context,e);this.bindingsByAction.set(e,t);this.delegate.bindingConnected(t)}disconnectAction(e){const t=this.bindingsByAction.get(e);if(t){this.bindingsByAction.delete(e);this.delegate.bindingDisconnected(t)}}disconnectAllActions(){this.bindings.forEach((e=>this.delegate.bindingDisconnected(e,true)));this.bindingsByAction.clear()}parseValueForToken(e){const t=Action.forToken(e,this.schema);if(t.identifier==this.identifier)return t}elementMatchedValue(e,t){this.connectAction(t)}elementUnmatchedValue(e,t){this.disconnectAction(t)}}class ValueObserver{constructor(e,t){this.context=e;this.receiver=t;this.stringMapObserver=new StringMapObserver(this.element,this);this.valueDescriptorMap=this.controller.valueDescriptorMap}start(){this.stringMapObserver.start();this.invokeChangedCallbacksForDefaultValues()}stop(){this.stringMapObserver.stop()}get element(){return this.context.element}get controller(){return this.context.controller}getStringMapKeyForAttribute(e){if(e in this.valueDescriptorMap)return this.valueDescriptorMap[e].name}stringMapKeyAdded(e,t){const r=this.valueDescriptorMap[t];this.hasValue(e)||this.invokeChangedCallback(e,r.writer(this.receiver[e]),r.writer(r.defaultValue))}stringMapValueChanged(e,t,r){const s=this.valueDescriptorNameMap[t];if(null!==e){null===r&&(r=s.writer(s.defaultValue));this.invokeChangedCallback(t,e,r)}}stringMapKeyRemoved(e,t,r){const s=this.valueDescriptorNameMap[e];this.hasValue(e)?this.invokeChangedCallback(e,s.writer(this.receiver[e]),r):this.invokeChangedCallback(e,s.writer(s.defaultValue),r)}invokeChangedCallbacksForDefaultValues(){for(const{key:e,name:t,defaultValue:r,writer:s}of this.valueDescriptors)void 0==r||this.controller.data.has(e)||this.invokeChangedCallback(t,s(r),void 0)}invokeChangedCallback(e,t,r){const s=`${e}Changed`;const n=this.receiver[s];if("function"==typeof n){const s=this.valueDescriptorNameMap[e];try{const e=s.reader(t);let i=r;r&&(i=s.reader(r));n.call(this.receiver,e,i)}catch(e){e instanceof TypeError&&(e.message=`Stimulus Value "${this.context.identifier}.${s.name}" - ${e.message}`);throw e}}}get valueDescriptors(){const{valueDescriptorMap:e}=this;return Object.keys(e).map((t=>e[t]))}get valueDescriptorNameMap(){const e={};Object.keys(this.valueDescriptorMap).forEach((t=>{const r=this.valueDescriptorMap[t];e[r.name]=r}));return e}hasValue(e){const t=this.valueDescriptorNameMap[e];const r=`has${capitalize(t.name)}`;return this.receiver[r]}}class TargetObserver{constructor(e,t){this.context=e;this.delegate=t;this.targetsByName=new Multimap}start(){if(!this.tokenListObserver){this.tokenListObserver=new TokenListObserver(this.element,this.attributeName,this);this.tokenListObserver.start()}}stop(){if(this.tokenListObserver){this.disconnectAllTargets();this.tokenListObserver.stop();delete this.tokenListObserver}}tokenMatched({element:e,content:t}){this.scope.containsElement(e)&&this.connectTarget(e,t)}tokenUnmatched({element:e,content:t}){this.disconnectTarget(e,t)}connectTarget(e,t){var r;if(!this.targetsByName.has(t,e)){this.targetsByName.add(t,e);null===(r=this.tokenListObserver)||void 0===r?void 0:r.pause((()=>this.delegate.targetConnected(e,t)))}}disconnectTarget(e,t){var r;if(this.targetsByName.has(t,e)){this.targetsByName.delete(t,e);null===(r=this.tokenListObserver)||void 0===r?void 0:r.pause((()=>this.delegate.targetDisconnected(e,t)))}}disconnectAllTargets(){for(const e of this.targetsByName.keys)for(const t of this.targetsByName.getValuesForKey(e))this.disconnectTarget(t,e)}get attributeName(){return`data-${this.context.identifier}-target`}get element(){return this.context.element}get scope(){return this.context.scope}}function readInheritableStaticArrayValues(e,t){const r=getAncestorsForConstructor(e);return Array.from(r.reduce(((e,r)=>{getOwnStaticArrayValues(r,t).forEach((t=>e.add(t)));return e}),new Set))}function readInheritableStaticObjectPairs(e,t){const r=getAncestorsForConstructor(e);return r.reduce(((e,r)=>{e.push(...getOwnStaticObjectPairs(r,t));return e}),[])}function getAncestorsForConstructor(e){const t=[];while(e){t.push(e);e=Object.getPrototypeOf(e)}return t.reverse()}function getOwnStaticArrayValues(e,t){const r=e[t];return Array.isArray(r)?r:[]}function getOwnStaticObjectPairs(e,t){const r=e[t];return r?Object.keys(r).map((e=>[e,r[e]])):[]}class OutletObserver{constructor(e,t){this.started=false;this.context=e;this.delegate=t;this.outletsByName=new Multimap;this.outletElementsByName=new Multimap;this.selectorObserverMap=new Map;this.attributeObserverMap=new Map}start(){if(!this.started){this.outletDefinitions.forEach((e=>{this.setupSelectorObserverForOutlet(e);this.setupAttributeObserverForOutlet(e)}));this.started=true;this.dependentContexts.forEach((e=>e.refresh()))}}refresh(){this.selectorObserverMap.forEach((e=>e.refresh()));this.attributeObserverMap.forEach((e=>e.refresh()))}stop(){if(this.started){this.started=false;this.disconnectAllOutlets();this.stopSelectorObservers();this.stopAttributeObservers()}}stopSelectorObservers(){if(this.selectorObserverMap.size>0){this.selectorObserverMap.forEach((e=>e.stop()));this.selectorObserverMap.clear()}}stopAttributeObservers(){if(this.attributeObserverMap.size>0){this.attributeObserverMap.forEach((e=>e.stop()));this.attributeObserverMap.clear()}}selectorMatched(e,t,{outletName:r}){const s=this.getOutlet(e,r);s&&this.connectOutlet(s,e,r)}selectorUnmatched(e,t,{outletName:r}){const s=this.getOutletFromMap(e,r);s&&this.disconnectOutlet(s,e,r)}selectorMatchElement(e,{outletName:t}){const r=this.selector(t);const s=this.hasOutlet(e,t);const n=e.matches(`[${this.schema.controllerAttribute}~=${t}]`);return!!r&&(s&&n&&e.matches(r))}elementMatchedAttribute(e,t){const r=this.getOutletNameFromOutletAttributeName(t);r&&this.updateSelectorObserverForOutlet(r)}elementAttributeValueChanged(e,t){const r=this.getOutletNameFromOutletAttributeName(t);r&&this.updateSelectorObserverForOutlet(r)}elementUnmatchedAttribute(e,t){const r=this.getOutletNameFromOutletAttributeName(t);r&&this.updateSelectorObserverForOutlet(r)}connectOutlet(e,t,r){var s;if(!this.outletElementsByName.has(r,t)){this.outletsByName.add(r,e);this.outletElementsByName.add(r,t);null===(s=this.selectorObserverMap.get(r))||void 0===s?void 0:s.pause((()=>this.delegate.outletConnected(e,t,r)))}}disconnectOutlet(e,t,r){var s;if(this.outletElementsByName.has(r,t)){this.outletsByName.delete(r,e);this.outletElementsByName.delete(r,t);null===(s=this.selectorObserverMap.get(r))||void 0===s?void 0:s.pause((()=>this.delegate.outletDisconnected(e,t,r)))}}disconnectAllOutlets(){for(const e of this.outletElementsByName.keys)for(const t of this.outletElementsByName.getValuesForKey(e))for(const r of this.outletsByName.getValuesForKey(e))this.disconnectOutlet(r,t,e)}updateSelectorObserverForOutlet(e){const t=this.selectorObserverMap.get(e);t&&(t.selector=this.selector(e))}setupSelectorObserverForOutlet(e){const t=this.selector(e);const r=new SelectorObserver(document.body,t,this,{outletName:e});this.selectorObserverMap.set(e,r);r.start()}setupAttributeObserverForOutlet(e){const t=this.attributeNameForOutletName(e);const r=new AttributeObserver(this.scope.element,t,this);this.attributeObserverMap.set(e,r);r.start()}selector(e){return this.scope.outlets.getSelectorForOutletName(e)}attributeNameForOutletName(e){return this.scope.schema.outletAttributeForScope(this.identifier,e)}getOutletNameFromOutletAttributeName(e){return this.outletDefinitions.find((t=>this.attributeNameForOutletName(t)===e))}get outletDependencies(){const e=new Multimap;this.router.modules.forEach((t=>{const r=t.definition.controllerConstructor;const s=readInheritableStaticArrayValues(r,"outlets");s.forEach((r=>e.add(r,t.identifier)))}));return e}get outletDefinitions(){return this.outletDependencies.getKeysForValue(this.identifier)}get dependentControllerIdentifiers(){return this.outletDependencies.getValuesForKey(this.identifier)}get dependentContexts(){const e=this.dependentControllerIdentifiers;return this.router.contexts.filter((t=>e.includes(t.identifier)))}hasOutlet(e,t){return!!this.getOutlet(e,t)||!!this.getOutletFromMap(e,t)}getOutlet(e,t){return this.application.getControllerForElementAndIdentifier(e,t)}getOutletFromMap(e,t){return this.outletsByName.getValuesForKey(t).find((t=>t.element===e))}get scope(){return this.context.scope}get schema(){return this.context.schema}get identifier(){return this.context.identifier}get application(){return this.context.application}get router(){return this.application.router}}class Context{constructor(e,t){this.logDebugActivity=(e,t={})=>{const{identifier:r,controller:s,element:n}=this;t=Object.assign({identifier:r,controller:s,element:n},t);this.application.logDebugActivity(this.identifier,e,t)};this.module=e;this.scope=t;this.controller=new e.controllerConstructor(this);this.bindingObserver=new BindingObserver(this,this.dispatcher);this.valueObserver=new ValueObserver(this,this.controller);this.targetObserver=new TargetObserver(this,this);this.outletObserver=new OutletObserver(this,this);try{this.controller.initialize();this.logDebugActivity("initialize")}catch(e){this.handleError(e,"initializing controller")}}connect(){this.bindingObserver.start();this.valueObserver.start();this.targetObserver.start();this.outletObserver.start();try{this.controller.connect();this.logDebugActivity("connect")}catch(e){this.handleError(e,"connecting controller")}}refresh(){this.outletObserver.refresh()}disconnect(){try{this.controller.disconnect();this.logDebugActivity("disconnect")}catch(e){this.handleError(e,"disconnecting controller")}this.outletObserver.stop();this.targetObserver.stop();this.valueObserver.stop();this.bindingObserver.stop()}get application(){return this.module.application}get identifier(){return this.module.identifier}get schema(){return this.application.schema}get dispatcher(){return this.application.dispatcher}get element(){return this.scope.element}get parentElement(){return this.element.parentElement}handleError(e,t,r={}){const{identifier:s,controller:n,element:i}=this;r=Object.assign({identifier:s,controller:n,element:i},r);this.application.handleError(e,`Error ${t}`,r)}targetConnected(e,t){this.invokeControllerMethod(`${t}TargetConnected`,e)}targetDisconnected(e,t){this.invokeControllerMethod(`${t}TargetDisconnected`,e)}outletConnected(e,t,r){this.invokeControllerMethod(`${namespaceCamelize(r)}OutletConnected`,e,t)}outletDisconnected(e,t,r){this.invokeControllerMethod(`${namespaceCamelize(r)}OutletDisconnected`,e,t)}invokeControllerMethod(e,...t){const r=this.controller;"function"==typeof r[e]&&r[e](...t)}}function bless(e){return shadow(e,getBlessedProperties(e))}function shadow(e,t){const r=i(e);const s=getShadowProperties(e.prototype,t);Object.defineProperties(r.prototype,s);return r}function getBlessedProperties(e){const t=readInheritableStaticArrayValues(e,"blessings");return t.reduce(((t,r)=>{const s=r(e);for(const e in s){const r=t[e]||{};t[e]=Object.assign(r,s[e])}return t}),{})}function getShadowProperties(e,t){return n(t).reduce(((r,s)=>{const n=getShadowedDescriptor(e,t,s);n&&Object.assign(r,{[s]:n});return r}),{})}function getShadowedDescriptor(e,t,r){const s=Object.getOwnPropertyDescriptor(e,r);const n=s&&"value"in s;if(!n){const e=Object.getOwnPropertyDescriptor(t,r).value;if(s){e.get=s.get||e.get;e.set=s.set||e.set}return e}}const n=(()=>"function"==typeof Object.getOwnPropertySymbols?e=>[...Object.getOwnPropertyNames(e),...Object.getOwnPropertySymbols(e)]:Object.getOwnPropertyNames)();const i=(()=>{function extendWithReflect(e){function extended(){return Reflect.construct(e,arguments,new.target)}extended.prototype=Object.create(e.prototype,{constructor:{value:extended}});Reflect.setPrototypeOf(extended,e);return extended}function testReflectExtension(){const a=function(){this.a.call(this)};const e=extendWithReflect(a);e.prototype.a=function(){};return new e}try{testReflectExtension();return extendWithReflect}catch(e){return e=>class extended extends e{}}})();function blessDefinition(e){return{identifier:e.identifier,controllerConstructor:bless(e.controllerConstructor)}}class Module{constructor(e,t){this.application=e;this.definition=blessDefinition(t);this.contextsByScope=new WeakMap;this.connectedContexts=new Set}get identifier(){return this.definition.identifier}get controllerConstructor(){return this.definition.controllerConstructor}get contexts(){return Array.from(this.connectedContexts)}connectContextForScope(e){const t=this.fetchContextForScope(e);this.connectedContexts.add(t);t.connect()}disconnectContextForScope(e){const t=this.contextsByScope.get(e);if(t){this.connectedContexts.delete(t);t.disconnect()}}fetchContextForScope(e){let t=this.contextsByScope.get(e);if(!t){t=new Context(this,e);this.contextsByScope.set(e,t)}return t}}class ClassMap{constructor(e){this.scope=e}has(e){return this.data.has(this.getDataKey(e))}get(e){return this.getAll(e)[0]}getAll(e){const t=this.data.get(this.getDataKey(e))||"";return tokenize(t)}getAttributeName(e){return this.data.getAttributeNameForKey(this.getDataKey(e))}getDataKey(e){return`${e}-class`}get data(){return this.scope.data}}class DataMap{constructor(e){this.scope=e}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get(e){const t=this.getAttributeNameForKey(e);return this.element.getAttribute(t)}set(e,t){const r=this.getAttributeNameForKey(e);this.element.setAttribute(r,t);return this.get(e)}has(e){const t=this.getAttributeNameForKey(e);return this.element.hasAttribute(t)}delete(e){if(this.has(e)){const t=this.getAttributeNameForKey(e);this.element.removeAttribute(t);return true}return false}getAttributeNameForKey(e){return`data-${this.identifier}-${dasherize(e)}`}}class Guide{constructor(e){this.warnedKeysByObject=new WeakMap;this.logger=e}warn(e,t,r){let s=this.warnedKeysByObject.get(e);if(!s){s=new Set;this.warnedKeysByObject.set(e,s)}if(!s.has(t)){s.add(t);this.logger.warn(r,e)}}}function attributeValueContainsToken(e,t){return`[${e}~="${t}"]`}class TargetSet{constructor(e){this.scope=e}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get schema(){return this.scope.schema}has(e){return null!=this.find(e)}find(...e){return e.reduce(((e,t)=>e||this.findTarget(t)||this.findLegacyTarget(t)),void 0)}findAll(...e){return e.reduce(((e,t)=>[...e,...this.findAllTargets(t),...this.findAllLegacyTargets(t)]),[])}findTarget(e){const t=this.getSelectorForTargetName(e);return this.scope.findElement(t)}findAllTargets(e){const t=this.getSelectorForTargetName(e);return this.scope.findAllElements(t)}getSelectorForTargetName(e){const t=this.schema.targetAttributeForScope(this.identifier);return attributeValueContainsToken(t,e)}findLegacyTarget(e){const t=this.getLegacySelectorForTargetName(e);return this.deprecate(this.scope.findElement(t),e)}findAllLegacyTargets(e){const t=this.getLegacySelectorForTargetName(e);return this.scope.findAllElements(t).map((t=>this.deprecate(t,e)))}getLegacySelectorForTargetName(e){const t=`${this.identifier}.${e}`;return attributeValueContainsToken(this.schema.targetAttribute,t)}deprecate(e,t){if(e){const{identifier:r}=this;const s=this.schema.targetAttribute;const n=this.schema.targetAttributeForScope(r);this.guide.warn(e,`target:${t}`,`Please replace ${s}="${r}.${t}" with ${n}="${t}". The ${s} attribute is deprecated and will be removed in a future version of Stimulus.`)}return e}get guide(){return this.scope.guide}}class OutletSet{constructor(e,t){this.scope=e;this.controllerElement=t}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get schema(){return this.scope.schema}has(e){return null!=this.find(e)}find(...e){return e.reduce(((e,t)=>e||this.findOutlet(t)),void 0)}findAll(...e){return e.reduce(((e,t)=>[...e,...this.findAllOutlets(t)]),[])}getSelectorForOutletName(e){const t=this.schema.outletAttributeForScope(this.identifier,e);return this.controllerElement.getAttribute(t)}findOutlet(e){const t=this.getSelectorForOutletName(e);if(t)return this.findElement(t,e)}findAllOutlets(e){const t=this.getSelectorForOutletName(e);return t?this.findAllElements(t,e):[]}findElement(e,t){const r=this.scope.queryElements(e);return r.filter((r=>this.matchesElement(r,e,t)))[0]}findAllElements(e,t){const r=this.scope.queryElements(e);return r.filter((r=>this.matchesElement(r,e,t)))}matchesElement(e,t,r){const s=e.getAttribute(this.scope.schema.controllerAttribute)||"";return e.matches(t)&&s.split(" ").includes(r)}}class Scope{constructor(e,t,r,s){this.targets=new TargetSet(this);this.classes=new ClassMap(this);this.data=new DataMap(this);this.containsElement=e=>e.closest(this.controllerSelector)===this.element;this.schema=e;this.element=t;this.identifier=r;this.guide=new Guide(s);this.outlets=new OutletSet(this.documentScope,t)}findElement(e){return this.element.matches(e)?this.element:this.queryElements(e).find(this.containsElement)}findAllElements(e){return[...this.element.matches(e)?[this.element]:[],...this.queryElements(e).filter(this.containsElement)]}queryElements(e){return Array.from(this.element.querySelectorAll(e))}get controllerSelector(){return attributeValueContainsToken(this.schema.controllerAttribute,this.identifier)}get isDocumentScope(){return this.element===document.documentElement}get documentScope(){return this.isDocumentScope?this:new Scope(this.schema,document.documentElement,this.identifier,this.guide.logger)}}class ScopeObserver{constructor(e,t,r){this.element=e;this.schema=t;this.delegate=r;this.valueListObserver=new ValueListObserver(this.element,this.controllerAttribute,this);this.scopesByIdentifierByElement=new WeakMap;this.scopeReferenceCounts=new WeakMap}start(){this.valueListObserver.start()}stop(){this.valueListObserver.stop()}get controllerAttribute(){return this.schema.controllerAttribute}parseValueForToken(e){const{element:t,content:r}=e;return this.parseValueForElementAndIdentifier(t,r)}parseValueForElementAndIdentifier(e,t){const r=this.fetchScopesByIdentifierForElement(e);let s=r.get(t);if(!s){s=this.delegate.createScopeForElementAndIdentifier(e,t);r.set(t,s)}return s}elementMatchedValue(e,t){const r=(this.scopeReferenceCounts.get(t)||0)+1;this.scopeReferenceCounts.set(t,r);1==r&&this.delegate.scopeConnected(t)}elementUnmatchedValue(e,t){const r=this.scopeReferenceCounts.get(t);if(r){this.scopeReferenceCounts.set(t,r-1);1==r&&this.delegate.scopeDisconnected(t)}}fetchScopesByIdentifierForElement(e){let t=this.scopesByIdentifierByElement.get(e);if(!t){t=new Map;this.scopesByIdentifierByElement.set(e,t)}return t}}class Router{constructor(e){this.application=e;this.scopeObserver=new ScopeObserver(this.element,this.schema,this);this.scopesByIdentifier=new Multimap;this.modulesByIdentifier=new Map}get element(){return this.application.element}get schema(){return this.application.schema}get logger(){return this.application.logger}get controllerAttribute(){return this.schema.controllerAttribute}get modules(){return Array.from(this.modulesByIdentifier.values())}get contexts(){return this.modules.reduce(((e,t)=>e.concat(t.contexts)),[])}start(){this.scopeObserver.start()}stop(){this.scopeObserver.stop()}loadDefinition(e){this.unloadIdentifier(e.identifier);const t=new Module(this.application,e);this.connectModule(t);const r=e.controllerConstructor.afterLoad;r&&r.call(e.controllerConstructor,e.identifier,this.application)}unloadIdentifier(e){const t=this.modulesByIdentifier.get(e);t&&this.disconnectModule(t)}getContextForElementAndIdentifier(e,t){const r=this.modulesByIdentifier.get(t);if(r)return r.contexts.find((t=>t.element==e))}proposeToConnectScopeForElementAndIdentifier(e,t){const r=this.scopeObserver.parseValueForElementAndIdentifier(e,t);r?this.scopeObserver.elementMatchedValue(r.element,r):console.error(`Couldn't find or create scope for identifier: "${t}" and element:`,e)}handleError(e,t,r){this.application.handleError(e,t,r)}createScopeForElementAndIdentifier(e,t){return new Scope(this.schema,e,t,this.logger)}scopeConnected(e){this.scopesByIdentifier.add(e.identifier,e);const t=this.modulesByIdentifier.get(e.identifier);t&&t.connectContextForScope(e)}scopeDisconnected(e){this.scopesByIdentifier.delete(e.identifier,e);const t=this.modulesByIdentifier.get(e.identifier);t&&t.disconnectContextForScope(e)}connectModule(e){this.modulesByIdentifier.set(e.identifier,e);const t=this.scopesByIdentifier.getValuesForKey(e.identifier);t.forEach((t=>e.connectContextForScope(t)))}disconnectModule(e){this.modulesByIdentifier.delete(e.identifier);const t=this.scopesByIdentifier.getValuesForKey(e.identifier);t.forEach((t=>e.disconnectContextForScope(t)))}}const o={controllerAttribute:"data-controller",actionAttribute:"data-action",targetAttribute:"data-target",targetAttributeForScope:e=>`data-${e}-target`,outletAttributeForScope:(e,t)=>`data-${e}-${t}-outlet`,keyMappings:Object.assign(Object.assign({enter:"Enter",tab:"Tab",esc:"Escape",space:" ",up:"ArrowUp",down:"ArrowDown",left:"ArrowLeft",right:"ArrowRight",home:"Home",end:"End",page_up:"PageUp",page_down:"PageDown"},objectFromEntries("abcdefghijklmnopqrstuvwxyz".split("").map((e=>[e,e])))),objectFromEntries("0123456789".split("").map((e=>[e,e]))))};function objectFromEntries(e){return e.reduce(((e,[t,r])=>Object.assign(Object.assign({},e),{[t]:r})),{})}class Application{constructor(t=document.documentElement,r=o){this.logger=console;this.debug=false;this.logDebugActivity=(e,t,r={})=>{this.debug&&this.logFormattedMessage(e,t,r)};this.element=t;this.schema=r;this.dispatcher=new Dispatcher(this);this.router=new Router(this);this.actionDescriptorFilters=Object.assign({},e)}static start(e,t){const r=new this(e,t);r.start();return r}async start(){await domReady();this.logDebugActivity("application","starting");this.dispatcher.start();this.router.start();this.logDebugActivity("application","start")}stop(){this.logDebugActivity("application","stopping");this.dispatcher.stop();this.router.stop();this.logDebugActivity("application","stop")}register(e,t){this.load({identifier:e,controllerConstructor:t})}registerActionOption(e,t){this.actionDescriptorFilters[e]=t}load(e,...t){const r=Array.isArray(e)?e:[e,...t];r.forEach((e=>{e.controllerConstructor.shouldLoad&&this.router.loadDefinition(e)}))}unload(e,...t){const r=Array.isArray(e)?e:[e,...t];r.forEach((e=>this.router.unloadIdentifier(e)))}get controllers(){return this.router.contexts.map((e=>e.controller))}getControllerForElementAndIdentifier(e,t){const r=this.router.getContextForElementAndIdentifier(e,t);return r?r.controller:null}handleError(e,t,r){var s;this.logger.error("%s\n\n%o\n\n%o",t,e,r);null===(s=window.onerror)||void 0===s?void 0:s.call(window,t,"",0,0,e)}logFormattedMessage(e,t,r={}){r=Object.assign({application:this},r);this.logger.groupCollapsed(`${e} #${t}`);this.logger.log("details:",Object.assign({},r));this.logger.groupEnd()}}function domReady(){return new Promise((e=>{"loading"==document.readyState?document.addEventListener("DOMContentLoaded",(()=>e())):e()}))}function ClassPropertiesBlessing(e){const t=readInheritableStaticArrayValues(e,"classes");return t.reduce(((e,t)=>Object.assign(e,propertiesForClassDefinition(t))),{})}function propertiesForClassDefinition(e){return{[`${e}Class`]:{get(){const{classes:t}=this;if(t.has(e))return t.get(e);{const r=t.getAttributeName(e);throw new Error(`Missing attribute "${r}"`)}}},[`${e}Classes`]:{get(){return this.classes.getAll(e)}},[`has${capitalize(e)}Class`]:{get(){return this.classes.has(e)}}}}function OutletPropertiesBlessing(e){const t=readInheritableStaticArrayValues(e,"outlets");return t.reduce(((e,t)=>Object.assign(e,propertiesForOutletDefinition(t))),{})}function getOutletController(e,t,r){return e.application.getControllerForElementAndIdentifier(t,r)}function getControllerAndEnsureConnectedScope(e,t,r){let s=getOutletController(e,t,r);if(s)return s;e.application.router.proposeToConnectScopeForElementAndIdentifier(t,r);s=getOutletController(e,t,r);return s||void 0}function propertiesForOutletDefinition(e){const t=namespaceCamelize(e);return{[`${t}Outlet`]:{get(){const t=this.outlets.find(e);const r=this.outlets.getSelectorForOutletName(e);if(t){const r=getControllerAndEnsureConnectedScope(this,t,e);if(r)return r;throw new Error(`The provided outlet element is missing an outlet controller "${e}" instance for host controller "${this.identifier}"`)}throw new Error(`Missing outlet element "${e}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${r}".`)}},[`${t}Outlets`]:{get(){const t=this.outlets.findAll(e);return t.length>0?t.map((t=>{const r=getControllerAndEnsureConnectedScope(this,t,e);if(r)return r;console.warn(`The provided outlet element is missing an outlet controller "${e}" instance for host controller "${this.identifier}"`,t)})).filter((e=>e)):[]}},[`${t}OutletElement`]:{get(){const t=this.outlets.find(e);const r=this.outlets.getSelectorForOutletName(e);if(t)return t;throw new Error(`Missing outlet element "${e}" for host controller "${this.identifier}". Stimulus couldn't find a matching outlet element using selector "${r}".`)}},[`${t}OutletElements`]:{get(){return this.outlets.findAll(e)}},[`has${capitalize(t)}Outlet`]:{get(){return this.outlets.has(e)}}}}function TargetPropertiesBlessing(e){const t=readInheritableStaticArrayValues(e,"targets");return t.reduce(((e,t)=>Object.assign(e,propertiesForTargetDefinition(t))),{})}function propertiesForTargetDefinition(e){return{[`${e}Target`]:{get(){const t=this.targets.find(e);if(t)return t;throw new Error(`Missing target element "${e}" for "${this.identifier}" controller`)}},[`${e}Targets`]:{get(){return this.targets.findAll(e)}},[`has${capitalize(e)}Target`]:{get(){return this.targets.has(e)}}}}function ValuePropertiesBlessing(e){const t=readInheritableStaticObjectPairs(e,"values");const r={valueDescriptorMap:{get(){return t.reduce(((e,t)=>{const r=parseValueDefinitionPair(t,this.identifier);const s=this.data.getAttributeNameForKey(r.key);return Object.assign(e,{[s]:r})}),{})}}};return t.reduce(((e,t)=>Object.assign(e,propertiesForValueDefinitionPair(t))),r)}function propertiesForValueDefinitionPair(e,t){const r=parseValueDefinitionPair(e,t);const{key:s,name:n,reader:i,writer:o}=r;return{[n]:{get(){const e=this.data.get(s);return null!==e?i(e):r.defaultValue},set(e){void 0===e?this.data.delete(s):this.data.set(s,o(e))}},[`has${capitalize(n)}`]:{get(){return this.data.has(s)||r.hasCustomDefaultValue}}}}function parseValueDefinitionPair([e,t],r){return valueDescriptorForTokenAndTypeDefinition({controller:r,token:e,typeDefinition:t})}function parseValueTypeConstant(e){switch(e){case Array:return"array";case Boolean:return"boolean";case Number:return"number";case Object:return"object";case String:return"string"}}function parseValueTypeDefault(e){switch(typeof e){case"boolean":return"boolean";case"number":return"number";case"string":return"string"}return Array.isArray(e)?"array":"[object Object]"===Object.prototype.toString.call(e)?"object":void 0}function parseValueTypeObject(e){const{controller:t,token:r,typeObject:s}=e;const n=isSomething(s.type);const i=isSomething(s.default);const o=n&&i;const c=n&&!i;const l=!n&&i;const h=parseValueTypeConstant(s.type);const u=parseValueTypeDefault(e.typeObject.default);if(c)return h;if(l)return u;if(h!==u){const e=t?`${t}.${r}`:r;throw new Error(`The specified default value for the Stimulus Value "${e}" must match the defined type "${h}". The provided default value of "${s.default}" is of type "${u}".`)}return o?h:void 0}function parseValueTypeDefinition(e){const{controller:t,token:r,typeDefinition:s}=e;const n={controller:t,token:r,typeObject:s};const i=parseValueTypeObject(n);const o=parseValueTypeDefault(s);const c=parseValueTypeConstant(s);const l=i||o||c;if(l)return l;const h=t?`${t}.${s}`:r;throw new Error(`Unknown value type "${h}" for "${r}" value`)}function defaultValueForDefinition(e){const t=parseValueTypeConstant(e);if(t)return c[t];const r=hasProperty(e,"default");const s=hasProperty(e,"type");const n=e;if(r)return n.default;if(s){const{type:e}=n;const t=parseValueTypeConstant(e);if(t)return c[t]}return e}function valueDescriptorForTokenAndTypeDefinition(e){const{token:t,typeDefinition:r}=e;const s=`${dasherize(t)}-value`;const n=parseValueTypeDefinition(e);return{type:n,key:s,name:camelize(s),get defaultValue(){return defaultValueForDefinition(r)},get hasCustomDefaultValue(){return void 0!==parseValueTypeDefault(r)},reader:l[n],writer:h[n]||h.default}}const c={get array(){return[]},boolean:false,number:0,get object(){return{}},string:""};const l={array(e){const t=JSON.parse(e);if(!Array.isArray(t))throw new TypeError(`expected value of type "array" but instead got value "${e}" of type "${parseValueTypeDefault(t)}"`);return t},boolean(e){return!("0"==e||"false"==String(e).toLowerCase())},number(e){return Number(e.replace(/_/g,""))},object(e){const t=JSON.parse(e);if(null===t||"object"!=typeof t||Array.isArray(t))throw new TypeError(`expected value of type "object" but instead got value "${e}" of type "${parseValueTypeDefault(t)}"`);return t},string(e){return e}};const h={default:writeString,array:writeJSON,object:writeJSON};function writeJSON(e){return JSON.stringify(e)}function writeString(e){return`${e}`}class Controller{constructor(e){this.context=e}static get shouldLoad(){return true}static afterLoad(e,t){}get application(){return this.context.application}get scope(){return this.context.scope}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get targets(){return this.scope.targets}get outlets(){return this.scope.outlets}get classes(){return this.scope.classes}get data(){return this.scope.data}initialize(){}connect(){}disconnect(){}dispatch(e,{target:t=this.element,detail:r={},prefix:s=this.identifier,bubbles:n=true,cancelable:i=true}={}){const o=s?`${s}:${e}`:e;const c=new CustomEvent(o,{detail:r,bubbles:n,cancelable:i});t.dispatchEvent(c);return c}}Controller.blessings=[ClassPropertiesBlessing,TargetPropertiesBlessing,ValuePropertiesBlessing,OutletPropertiesBlessing];Controller.targets=[];Controller.outlets=[];Controller.values={};export{Application,AttributeObserver,Context,Controller,ElementObserver,IndexedMultimap,Multimap,SelectorObserver,StringMapObserver,TokenListObserver,ValueListObserver,add,o as defaultSchema,del,fetch,prune}; + From c586e26f373de4c9ad39baf4dbab389aa2c983f8 Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 29 Sep 2025 22:29:07 +0900 Subject: [PATCH 12/37] Update button text for clarity in simulation form --- serverside_challenge_2/challenge/app/views/plans/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/views/plans/index.html.erb b/serverside_challenge_2/challenge/app/views/plans/index.html.erb index a8ef9341f..e16587f0a 100644 --- a/serverside_challenge_2/challenge/app/views/plans/index.html.erb +++ b/serverside_challenge_2/challenge/app/views/plans/index.html.erb @@ -9,7 +9,7 @@
From bc5c5ae6ad23b86571a6411a3ceed7a314389cee Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 29 Sep 2025 22:34:52 +0900 Subject: [PATCH 13/37] remove debug --- .../challenge/app/javascript/controllers/plans_controller.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js index de7b8851f..71b9b1dfa 100644 --- a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js +++ b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js @@ -3,10 +3,6 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["ampere", "usage", "results"] - connect() { - console.log("PlansController connected"); - } - async search(event) { event.preventDefault(); From aba0c62523823ab768b5c8cfe710e50fdfbff0d7 Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 29 Sep 2025 22:35:44 +0900 Subject: [PATCH 14/37] remove debug --- .../challenge/app/javascript/controllers/plans_controller.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js index 71b9b1dfa..b4901aa19 100644 --- a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js +++ b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js @@ -12,11 +12,9 @@ export default class extends Controller { try { const response = await fetch(`/plans/prices?ampere=${ampere}&usage=${usage}`); - console.log(response); if (!response.ok) throw new Error("API request failed"); const data = await response.json(); if (Array.isArray(data) && data.length > 0) { - console.log(data); data.forEach(plan => { const row = this.createRow(plan); this.resultsTarget.appendChild(row); From 2d388e30a1ee5875b2193d7bd62c16101557b928 Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 29 Sep 2025 22:57:01 +0900 Subject: [PATCH 15/37] style --- .../app/assets/stylesheets/application.css | 46 +++++++++++++++++++ .../app/views/layouts/application.html.erb | 1 + .../challenge/app/views/plans/index.html.erb | 24 ++++++---- 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 serverside_challenge_2/challenge/app/assets/stylesheets/application.css diff --git a/serverside_challenge_2/challenge/app/assets/stylesheets/application.css b/serverside_challenge_2/challenge/app/assets/stylesheets/application.css new file mode 100644 index 000000000..03961ee5d --- /dev/null +++ b/serverside_challenge_2/challenge/app/assets/stylesheets/application.css @@ -0,0 +1,46 @@ +.sim-form { + display: flex; + gap: 16px; + align-items: flex-end; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-control { + padding: 6px 10px; + font-size: 14px; +} + +.btn { + padding: 8px 16px; + background-color: #0070f3; + color: #fff; + border: none; + cursor: pointer; + border-radius: 6px; +} + +.btn:hover { + background-color: #005bb5; +} + +.result-table { + width: 100%; + border-collapse: collapse; +} + +.result-table th, +.result-table td { + padding: 10px; + border-bottom: 1px solid #ddd; + text-align: left; +} + +.result-table tr:nth-child(even) { + background-color: #f9f9f9; +} diff --git a/serverside_challenge_2/challenge/app/views/layouts/application.html.erb b/serverside_challenge_2/challenge/app/views/layouts/application.html.erb index 4ea9beb4f..3cead9655 100644 --- a/serverside_challenge_2/challenge/app/views/layouts/application.html.erb +++ b/serverside_challenge_2/challenge/app/views/layouts/application.html.erb @@ -4,6 +4,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= javascript_importmap_tags %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= yield %> diff --git a/serverside_challenge_2/challenge/app/views/plans/index.html.erb b/serverside_challenge_2/challenge/app/views/plans/index.html.erb index e16587f0a..4f1dacdec 100644 --- a/serverside_challenge_2/challenge/app/views/plans/index.html.erb +++ b/serverside_challenge_2/challenge/app/views/plans/index.html.erb @@ -1,19 +1,23 @@

電気料金のシミュレーション

-
-
- - <%= select_tag :ampere, options_for_select(PlansController::PERMITTED_AMPERES), data: { plans_target: "ampere" } %> -
- - -
-
- +
From cf95668a720c9ccd107b1b7ad49cff0a8d5ae37f Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 29 Sep 2025 23:20:49 +0900 Subject: [PATCH 16/37] fix README --- serverside_challenge_2/challenge/README.md | 66 ++++++++++++++-------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/serverside_challenge_2/challenge/README.md b/serverside_challenge_2/challenge/README.md index 7db80e4ca..67a871269 100644 --- a/serverside_challenge_2/challenge/README.md +++ b/serverside_challenge_2/challenge/README.md @@ -1,24 +1,42 @@ -# README - -This README would normally document whatever steps are necessary to get the -application up and running. - -Things you may want to cover: - -* Ruby version - -* System dependencies - -* Configuration - -* Database creation - -* Database initialization - -* How to run the test suite - -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... +# これは +電気料金のシミュレーションを行えるサービスです。 + +The site is hosted: TODO + +# 構成 +- Back-end + - Ruby 3.1.2, Ruby on Rails 7.0.8 +- DB + - PostgreSQL +- Front-end + - erb, Stimulus, CSS +- Asset + - importmap, propshaft +- Test + - Rspec, FactoryBot, Shoulda Matchers +- Dev + - annotate gem + +# railsサーバー起動 +```sh +docker compose up -d +``` + +## データベース作成 +```sh +docker compose run -it web rails db:create +``` + +## データベース初期化、データ作成 +```sh +docker compose run -it web rails db:reset +``` + +# rspec実行 +```sh +docker compose run -it web rspec +``` + +# 電気料金のシミュレーション + +http://localhost:3000/plans にアクセス From 62f7f5452d6951428b9cde1b6742e94441d20d34 Mon Sep 17 00:00:00 2001 From: shigenius <33482612+shigenius@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:56:19 +0900 Subject: [PATCH 17/37] Update README.md --- serverside_challenge_2/challenge/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/serverside_challenge_2/challenge/README.md b/serverside_challenge_2/challenge/README.md index 67a871269..16c3ab4b3 100644 --- a/serverside_challenge_2/challenge/README.md +++ b/serverside_challenge_2/challenge/README.md @@ -1,8 +1,6 @@ # これは 電気料金のシミュレーションを行えるサービスです。 -The site is hosted: TODO - # 構成 - Back-end - Ruby 3.1.2, Ruby on Rails 7.0.8 @@ -17,7 +15,8 @@ The site is hosted: TODO - Dev - annotate gem -# railsサーバー起動 +# 開発環境 +## railsサーバー起動 ```sh docker compose up -d ``` @@ -32,11 +31,11 @@ docker compose run -it web rails db:create docker compose run -it web rails db:reset ``` -# rspec実行 +## rspec実行 ```sh docker compose run -it web rspec ``` -# 電気料金のシミュレーション +## 電気料金のシミュレーション http://localhost:3000/plans にアクセス From 469da2aea0b57f5b1e63136f00a3922268088b17 Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 21:53:53 +0900 Subject: [PATCH 18/37] fix comment --- .../challenge/app/controllers/plans_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/controllers/plans_controller.rb b/serverside_challenge_2/challenge/app/controllers/plans_controller.rb index 38de51d4d..c0abda766 100644 --- a/serverside_challenge_2/challenge/app/controllers/plans_controller.rb +++ b/serverside_challenge_2/challenge/app/controllers/plans_controller.rb @@ -25,7 +25,7 @@ def validate_prices_params raise ArgumentError, 'Ampere and usage must be provided' end - # 契約アンペア数 : 10 / 15 / 20 / 30 / 40 / 50 / 60 のいずれかとする(単位A) + # 契約アンペア数 : PERMITTED_AMPERES のいずれかとする(単位A) unless ampere.match?(/\A\d+\z/) && PERMITTED_AMPERES.include?(ampere.to_i) raise ArgumentError, "Ampere must be one of #{PERMITTED_AMPERES.join(', ')}" end From 2b0e6a18db8ee102b98eb36181111d0644acc721 Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 22:15:00 +0900 Subject: [PATCH 19/37] model validations --- .../challenge/app/models/basic_fee.rb | 3 ++ .../challenge/app/models/plan.rb | 2 ++ .../challenge/app/models/provider.rb | 2 ++ .../challenge/app/models/usage_charge.rb | 5 +++ .../challenge/spec/models/basic_fee_spec.rb | 7 ++++ .../challenge/spec/models/plan_spec.rb | 4 +++ .../challenge/spec/models/provider_spec.rb | 4 +++ .../spec/models/usage_charge_spec.rb | 35 +++++++++++++++++++ 8 files changed, 62 insertions(+) diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb index aa6ebf45c..0e095e006 100644 --- a/serverside_challenge_2/challenge/app/models/basic_fee.rb +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -1,6 +1,9 @@ class BasicFee < ApplicationRecord belongs_to :plan + validates :ampere, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :fee, presence: true, numericality: { greater_than_or_equal_to: 0 } + # @param ampere [Integer] 契約アンペア数(A) scope :by_ampere, ->(ampere) { where(ampere:) } end diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index 02691b43a..1eafd4949 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -3,6 +3,8 @@ class Plan < ApplicationRecord has_many :basic_fees, dependent: :destroy has_many :usage_charges, dependent: :destroy + validates :name, presence: true + # 契約アンペア数と電気使用量に基づき、該当する料金プランの一覧を取得する # @param ampere [Integer] 契約アンペア数(A) # @param usage [Integer] 電気使用量(kWh) diff --git a/serverside_challenge_2/challenge/app/models/provider.rb b/serverside_challenge_2/challenge/app/models/provider.rb index fe3e3b9cf..cf2b83653 100644 --- a/serverside_challenge_2/challenge/app/models/provider.rb +++ b/serverside_challenge_2/challenge/app/models/provider.rb @@ -1,5 +1,7 @@ class Provider < ApplicationRecord has_many :plans, dependent: :destroy + + validates :name, presence: true end # == Schema Information diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index b41334eaa..a83f3fa21 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -1,10 +1,15 @@ class UsageCharge < ApplicationRecord belongs_to :plan + validates :unit_price, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :usage_lower, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :usage_upper, numericality: { only_integer: true, greater_than: :usage_lower }, allow_nil: true + # @param usage [Integer] 電気使用量(kWh) # NOTE: usage_upper = nullの場合は上限なしとみなす scope :by_usage, ->(usage) { where("usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?)", usage, usage) } + # 従量料金 = 従量料金単価(円/kWh) × 電気使用量(kWh) # @param usage [Integer] 電気使用量(kWh) # @return [BigDecimal] 従量料金(円) def calc_charge(usage) diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb index 91bdf64ef..d3d0631db 100644 --- a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -5,6 +5,13 @@ it { is_expected.to belong_to(:plan) } end + describe 'validations' do + it { is_expected.to validate_presence_of(:ampere) } + it { is_expected.to validate_numericality_of(:ampere).only_integer.is_greater_than(0) } + it { is_expected.to validate_presence_of(:fee) } + it { is_expected.to validate_numericality_of(:fee).is_greater_than_or_equal_to(0) } + end + describe 'scopes' do describe '.by_ampere' do subject { described_class.by_ampere(ampere) } diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index 84d3429f3..08881cf8b 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -43,6 +43,10 @@ end end + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + end + describe '.plan_prices' do subject { described_class.plan_prices(ampere:, usage:) } diff --git a/serverside_challenge_2/challenge/spec/models/provider_spec.rb b/serverside_challenge_2/challenge/spec/models/provider_spec.rb index fa0fb8640..4b15cc47e 100644 --- a/serverside_challenge_2/challenge/spec/models/provider_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/provider_spec.rb @@ -4,6 +4,10 @@ describe 'associations' do it { is_expected.to have_many(:plans).dependent(:destroy) } end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + end end # == Schema Information diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index 85da5cb5b..d3195a83a 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -35,6 +35,41 @@ expect(subject).to be_within(0.01).of(2206.68) end end + + describe 'validations' do + it { should validate_presence_of(:unit_price) } + it { should validate_numericality_of(:unit_price).is_greater_than_or_equal_to(0) } + it { should validate_presence_of(:usage_lower) } + # it { should validate_numericality_of(:usage_upper).only_integer.is_greater_than(:usage_lower).allow_nil } + describe 'usage_upper is greater than usage_lower' do + it 'is invalid if usage_lower < usage_upper' do + record = build(:usage_charge, + usage_lower: 100, + usage_upper: 101, + ) + + expect(record).to be_valid + end + + it 'is valid if usage_upper usage_lower > usage_upper' do + record = build(:usage_charge, + usage_lower: 100, + usage_upper: 99, + ) + + expect(record).to be_invalid + end + + it 'is valid if usage_upper is usage_lower == usage_upper' do + record = build(:usage_charge, + usage_lower: 100, + usage_upper: 100, + ) + + expect(record).to be_invalid + end + end + end end # == Schema Information From eeddc9ee67f012b830c5dc953743976bc2d6ea4e Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 22:25:54 +0900 Subject: [PATCH 20/37] fix usage_charges index --- serverside_challenge_2/challenge/app/models/usage_charge.rb | 5 +++-- .../db/migrate/20250928070132_create_usage_charges.rb | 1 + serverside_challenge_2/challenge/db/schema.rb | 1 + .../challenge/spec/factories/usage_charges.rb | 5 +++-- .../challenge/spec/models/usage_charge_spec.rb | 5 +++-- serverside_challenge_2/challenge/spec/requests/plans_spec.rb | 2 +- 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index a83f3fa21..37ce53f8b 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -31,8 +31,9 @@ def calc_charge(usage) # # Indexes # -# index_usage_charges_on_plan_id (plan_id) -# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# index_usage_charges_on_plan_id (plan_id) +# index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper (plan_id,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) # # Foreign Keys # diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb index 9b0799f00..ac47edc33 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb @@ -9,5 +9,6 @@ def change end add_index :usage_charges, [:usage_lower, :usage_upper] + add_index :usage_charges, [:plan_id, :usage_lower, :usage_upper] end end diff --git a/serverside_challenge_2/challenge/db/schema.rb b/serverside_challenge_2/challenge/db/schema.rb index d99c169be..0175a2507 100644 --- a/serverside_challenge_2/challenge/db/schema.rb +++ b/serverside_challenge_2/challenge/db/schema.rb @@ -46,6 +46,7 @@ t.decimal "unit_price", precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["plan_id", "usage_lower", "usage_upper"], name: "index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper" t.index ["plan_id"], name: "index_usage_charges_on_plan_id" t.index ["usage_lower", "usage_upper"], name: "index_usage_charges_on_usage_lower_and_usage_upper" end diff --git a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb index af042ee4d..6d748f2be 100644 --- a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb +++ b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb @@ -21,8 +21,9 @@ # # Indexes # -# index_usage_charges_on_plan_id (plan_id) -# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# index_usage_charges_on_plan_id (plan_id) +# index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper (plan_id,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) # # Foreign Keys # diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index d3195a83a..abe722630 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -86,8 +86,9 @@ # # Indexes # -# index_usage_charges_on_plan_id (plan_id) -# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# index_usage_charges_on_plan_id (plan_id) +# index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper (plan_id,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) # # Foreign Keys # diff --git a/serverside_challenge_2/challenge/spec/requests/plans_spec.rb b/serverside_challenge_2/challenge/spec/requests/plans_spec.rb index f4cc33b6e..09d50c2e8 100644 --- a/serverside_challenge_2/challenge/spec/requests/plans_spec.rb +++ b/serverside_challenge_2/challenge/spec/requests/plans_spec.rb @@ -101,7 +101,7 @@ context 'usage is float' do let(:params) { { ampere: 30, usage: 100.5 } } it_behaves_like 'bad request', 'Usage must be a non-negative integer' - end + end end end end From 84c7430fb61f6c704f5771d0f70a1c76b486e21e Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 22:38:48 +0900 Subject: [PATCH 21/37] fix comment --- serverside_challenge_2/challenge/app/models/plan.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index 1eafd4949..a70bd2739 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -40,7 +40,9 @@ def self.plan_prices(ampere:, usage:) private - # 電気料金 = ①基本料金 + ②従量料金 + # 電気料金 = ①基本料金 + ②従量料金 + ③そのほか + # NOTE: 現時点では③そのほかは考慮しない + # NOTE: もし他の箇所で同じように料金を計算する必要が出てきた場合や、そのほかを計算し複雑化する場合は、電気料金の値オブジェクト化を検討すること # @param basic_fee [BasicFee] 基本料金オブジェクト # @param usage_charge [UsageCharge] 従量料金オブジェクト # @param usage [Integer] 電気使用量(kWh) From 61588c20971711a3b7c949bfed2d4db6db96176a Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 22:38:59 +0900 Subject: [PATCH 22/37] refactor plan_spec --- .../challenge/spec/models/plan_spec.rb | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index 08881cf8b..11c66d327 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -50,30 +50,32 @@ describe '.plan_prices' do subject { described_class.plan_prices(ampere:, usage:) } - let(:provider1) { create(:provider, name: '東京電力エナジーパートナー') } - let(:provider2) { create(:provider, name: '東京ガス') } - let(:provider3) { create(:provider, name: 'Looopでんき') } - let(:plan1) { create(:plan, name: '従量電灯B', provider: provider1) } - let(:plan2) { create(:plan, name: 'スタンダードS', provider: provider1) } - let(:plan3) { create(:plan, name: 'ずっとも電気1', provider: provider2) } - let(:plan4) { create(:plan, name: 'おうちプラン', provider: provider3) } - let!(:basic_fee1) { create(:basic_fee, plan: plan1, ampere: 10, fee: 286.0) } - let!(:basic_fee2) { create(:basic_fee, plan: plan1, ampere: 30, fee: 858.00) } - let!(:basic_fee3) { create(:basic_fee, plan: plan2, ampere: 10, fee: 311.75) } - let!(:basic_fee4) { create(:basic_fee, plan: plan2, ampere: 30, fee: 935.25) } - let!(:basic_fee5) { create(:basic_fee, plan: plan3, ampere: 30, fee: 858.00) } - let!(:basic_fee6) { create(:basic_fee, plan: plan4, ampere: 10, fee: 0.00) } - let!(:basic_fee7) { create(:basic_fee, plan: plan4, ampere: 30, fee: 0.00) } - let!(:usage_charge1) { create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } - let!(:usage_charge2) { create(:usage_charge, plan: plan1, usage_lower: 121, usage_upper: 300, unit_price: 26.48) } - let!(:usage_charge3) { create(:usage_charge, plan: plan1, usage_lower: 301, usage_upper: nil, unit_price: 30.57) } - let!(:usage_charge4) { create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: 120, unit_price: 29.80) } - let!(:usage_charge5) { create(:usage_charge, plan: plan2, usage_lower: 121, usage_upper: 300, unit_price: 36.40) } - let!(:usage_charge6) { create(:usage_charge, plan: plan2, usage_lower: 301, usage_upper: nil, unit_price: 40.49) } - let!(:usage_charge7) { create(:usage_charge, plan: plan3, usage_lower: 0, usage_upper: 120, unit_price: 23.67) } - let!(:usage_charge8) { create(:usage_charge, plan: plan3, usage_lower: 121, usage_upper: 300, unit_price: 23.88) } - let!(:usage_charge9) { create(:usage_charge, plan: plan3, usage_lower: 301, usage_upper: nil, unit_price: 26.41) } - let!(:usage_charge10) { create(:usage_charge, plan: plan4, usage_lower: 0, usage_upper: nil, unit_price: 28.8) } + before do + provider1 = create(:provider, name: '東京電力エナジーパートナー') + provider2 = create(:provider, name: '東京ガス') + provider3 = create(:provider, name: 'Looopでんき') + plan1 = create(:plan, name: '従量電灯B', provider: provider1) + plan2 = create(:plan, name: 'スタンダードS', provider: provider1) + plan3 = create(:plan, name: 'ずっとも電気1', provider: provider2) + plan4 = create(:plan, name: 'おうちプラン', provider: provider3) + create(:basic_fee, plan: plan1, ampere: 10, fee: 286.0) + create(:basic_fee, plan: plan1, ampere: 30, fee: 858.00) + create(:basic_fee, plan: plan2, ampere: 10, fee: 311.75) + create(:basic_fee, plan: plan2, ampere: 30, fee: 935.25) + create(:basic_fee, plan: plan3, ampere: 30, fee: 858.00) + create(:basic_fee, plan: plan4, ampere: 10, fee: 0.00) + create(:basic_fee, plan: plan4, ampere: 30, fee: 0.00) + create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 19.88) + create(:usage_charge, plan: plan1, usage_lower: 121, usage_upper: 300, unit_price: 26.48) + create(:usage_charge, plan: plan1, usage_lower: 301, usage_upper: nil, unit_price: 30.57) + create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: 120, unit_price: 29.80) + create(:usage_charge, plan: plan2, usage_lower: 121, usage_upper: 300, unit_price: 36.40) + create(:usage_charge, plan: plan2, usage_lower: 301, usage_upper: nil, unit_price: 40.49) + create(:usage_charge, plan: plan3, usage_lower: 0, usage_upper: 120, unit_price: 23.67) + create(:usage_charge, plan: plan3, usage_lower: 121, usage_upper: 300, unit_price: 23.88) + create(:usage_charge, plan: plan3, usage_lower: 301, usage_upper: nil, unit_price: 26.41) + create(:usage_charge, plan: plan4, usage_lower: 0, usage_upper: nil, unit_price: 28.8) + end context 'アンペア10A、使用量120kWhの場合' do let(:ampere) { 10 } From 6b19b85bdf5692f4929bdbe828280621f4e0928c Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 22:47:36 +0900 Subject: [PATCH 23/37] refactor: rename createRow to buildRow for consistency --- .../app/javascript/controllers/plans_controller.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js index b4901aa19..3fdce2dd9 100644 --- a/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js +++ b/serverside_challenge_2/challenge/app/javascript/controllers/plans_controller.js @@ -16,7 +16,7 @@ export default class extends Controller { const data = await response.json(); if (Array.isArray(data) && data.length > 0) { data.forEach(plan => { - const row = this.createRow(plan); + const row = this.buildRow(plan); this.resultsTarget.appendChild(row); }); } else { @@ -27,12 +27,12 @@ export default class extends Controller { } } - createRow(object) { + buildRow(plan) { const row = document.createElement("tr"); row.innerHTML = ` - - - + + + `; return row; } From b7a6840ea064e3934661fd32b24f52e73b2d63b5 Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 23:01:47 +0900 Subject: [PATCH 24/37] add erd --- serverside_challenge_2/challenge/Dockerfile | 2 +- serverside_challenge_2/challenge/Gemfile | 1 + serverside_challenge_2/challenge/Gemfile.lock | 10 ++++++++++ serverside_challenge_2/challenge/README.md | 15 +++++++++++++++ serverside_challenge_2/challenge/erd.pdf | Bin 0 -> 33135 bytes 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 serverside_challenge_2/challenge/erd.pdf diff --git a/serverside_challenge_2/challenge/Dockerfile b/serverside_challenge_2/challenge/Dockerfile index ceeea154e..2bf93e030 100644 --- a/serverside_challenge_2/challenge/Dockerfile +++ b/serverside_challenge_2/challenge/Dockerfile @@ -1,6 +1,6 @@ FROM ruby:3.1.2 -RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim +RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim graphviz RUN useradd -ms /bin/bash rails RUN mkdir /app # Gemfile をコピー(権限を rails に変更) diff --git a/serverside_challenge_2/challenge/Gemfile b/serverside_challenge_2/challenge/Gemfile index 25cc2e630..da965188c 100644 --- a/serverside_challenge_2/challenge/Gemfile +++ b/serverside_challenge_2/challenge/Gemfile @@ -46,6 +46,7 @@ group :development, :test do gem 'rspec-rails', '~> 6.0' gem 'shoulda-matchers', '~> 5.0' gem 'factory_bot_rails' + gem "rails-erd" end group :development do diff --git a/serverside_challenge_2/challenge/Gemfile.lock b/serverside_challenge_2/challenge/Gemfile.lock index cedc746b8..1e25c474c 100644 --- a/serverside_challenge_2/challenge/Gemfile.lock +++ b/serverside_challenge_2/challenge/Gemfile.lock @@ -72,6 +72,7 @@ GEM bootsnap (1.18.3) msgpack (~> 1.2) builder (3.2.4) + choice (0.2.0) concurrent-ruby (1.2.3) crass (1.0.6) date (3.3.4) @@ -155,6 +156,11 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) + rails-erd (1.7.2) + activerecord (>= 4.2) + activesupport (>= 4.2) + choice (~> 0.2.0) + ruby-graphviz (~> 1.2) rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) @@ -170,6 +176,7 @@ GEM psych (>= 4.0.0) reline (0.4.2) io-console (~> 0.5) + rexml (3.4.4) rspec-core (3.13.5) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -187,6 +194,8 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.6) + ruby-graphviz (1.2.5) + rexml shoulda-matchers (5.3.0) activesupport (>= 5.2.0) stimulus-rails (1.3.4) @@ -215,6 +224,7 @@ DEPENDENCIES propshaft puma (~> 5.0) rails (~> 7.0.8) + rails-erd rspec-rails (~> 6.0) shoulda-matchers (~> 5.0) stimulus-rails diff --git a/serverside_challenge_2/challenge/README.md b/serverside_challenge_2/challenge/README.md index 16c3ab4b3..0ffdb16b9 100644 --- a/serverside_challenge_2/challenge/README.md +++ b/serverside_challenge_2/challenge/README.md @@ -39,3 +39,18 @@ docker compose run -it web rspec ## 電気料金のシミュレーション http://localhost:3000/plans にアクセス + + +# ドキュメントの更新 + +annotationの強制更新 ※ 基本的にmigrate実行時に更新されるので通常は不要 + +```sh +docker compose run -it web bundle exec annotate --force +``` + +`erd.pdf`の更新 + +```sh +docker compose run -it web rake erd +``` \ No newline at end of file diff --git a/serverside_challenge_2/challenge/erd.pdf b/serverside_challenge_2/challenge/erd.pdf new file mode 100644 index 0000000000000000000000000000000000000000..29d75629e91de49d9893ef4e6890482f6906206b GIT binary patch literal 33135 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 JSXKn_{{n%UQ5OIJ literal 0 HcmV?d00001 From c09a096398ae74f0c9e9b2d20a59ae4b81025ffb Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 23:07:19 +0900 Subject: [PATCH 25/37] fix: add missing newline at end of providers.csv --- serverside_challenge_2/challenge/db/seeds/providers.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/db/seeds/providers.csv b/serverside_challenge_2/challenge/db/seeds/providers.csv index bd728ab31..8c7fc59fa 100644 --- a/serverside_challenge_2/challenge/db/seeds/providers.csv +++ b/serverside_challenge_2/challenge/db/seeds/providers.csv @@ -1,4 +1,4 @@ id,name 1,東京電力エナジーパートナー 2,東京ガス -3,Looopでんき \ No newline at end of file +3,Looopでんき From 6210eb12eb7df1c8739a2e204aa046aafb393f84 Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 23:46:50 +0900 Subject: [PATCH 26/37] add setup.sh --- serverside_challenge_2/challenge/README.md | 38 +++++++++++-------- .../challenge/scripts/setup.sh | 6 +++ 2 files changed, 28 insertions(+), 16 deletions(-) create mode 100755 serverside_challenge_2/challenge/scripts/setup.sh diff --git a/serverside_challenge_2/challenge/README.md b/serverside_challenge_2/challenge/README.md index 0ffdb16b9..937fa63df 100644 --- a/serverside_challenge_2/challenge/README.md +++ b/serverside_challenge_2/challenge/README.md @@ -13,44 +13,50 @@ - Test - Rspec, FactoryBot, Shoulda Matchers - Dev - - annotate gem + - annotate, rails-erd # 開発環境 -## railsサーバー起動 +## 初回セットアップ ```sh -docker compose up -d +./scripts/setup.sh ``` -## データベース作成 +- DB作成 +- seedデータ作成 +- railsサーバー起動 + +まで + +## 電気料金のシミュレーション + +http://localhost:3000/plans にアクセス + +## 基本コマンド +### railsサーバー起動 ```sh -docker compose run -it web rails db:create +docker compose up -d ``` -## データベース初期化、データ作成 +### データベース初期化、seedデータ作成 ```sh -docker compose run -it web rails db:reset +docker compose run --rm web rails db:reset ``` -## rspec実行 +### rspec実行 ```sh -docker compose run -it web rspec +docker compose run --rm web rspec ``` -## 電気料金のシミュレーション - -http://localhost:3000/plans にアクセス - - # ドキュメントの更新 annotationの強制更新 ※ 基本的にmigrate実行時に更新されるので通常は不要 ```sh -docker compose run -it web bundle exec annotate --force +docker compose run --rm web bundle exec annotate --force ``` `erd.pdf`の更新 ```sh -docker compose run -it web rake erd +docker compose run --rm web rake erd ``` \ No newline at end of file diff --git a/serverside_challenge_2/challenge/scripts/setup.sh b/serverside_challenge_2/challenge/scripts/setup.sh new file mode 100755 index 000000000..7012387cb --- /dev/null +++ b/serverside_challenge_2/challenge/scripts/setup.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -ex + +docker compose build +docker compose run --rm web rails db:setup +docker compose up -d \ No newline at end of file From 011302f80ca30862c0385132b77d0a6c6ed0428e Mon Sep 17 00:00:00 2001 From: shigenius Date: Thu, 2 Oct 2025 23:50:23 +0900 Subject: [PATCH 27/37] newline --- serverside_challenge_2/challenge/scripts/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/scripts/setup.sh b/serverside_challenge_2/challenge/scripts/setup.sh index 7012387cb..5cf83fa76 100755 --- a/serverside_challenge_2/challenge/scripts/setup.sh +++ b/serverside_challenge_2/challenge/scripts/setup.sh @@ -3,4 +3,4 @@ set -ex docker compose build docker compose run --rm web rails db:setup -docker compose up -d \ No newline at end of file +docker compose up -d From 7a2df9dc7a9e874161cbda1f5a971aa0651f9dde Mon Sep 17 00:00:00 2001 From: shigenius Date: Sat, 4 Oct 2025 20:36:57 +0900 Subject: [PATCH 28/37] Bug: Test Description Mismatch in Charge Spec --- .../challenge/spec/models/usage_charge_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index abe722630..acea1074a 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -42,7 +42,7 @@ it { should validate_presence_of(:usage_lower) } # it { should validate_numericality_of(:usage_upper).only_integer.is_greater_than(:usage_lower).allow_nil } describe 'usage_upper is greater than usage_lower' do - it 'is invalid if usage_lower < usage_upper' do + it 'is valid if usage_lower < usage_upper' do record = build(:usage_charge, usage_lower: 100, usage_upper: 101, @@ -51,7 +51,7 @@ expect(record).to be_valid end - it 'is valid if usage_upper usage_lower > usage_upper' do + it 'is invalid if usage_upper usage_lower > usage_upper' do record = build(:usage_charge, usage_lower: 100, usage_upper: 99, @@ -60,7 +60,7 @@ expect(record).to be_invalid end - it 'is valid if usage_upper is usage_lower == usage_upper' do + it 'is invalid if usage_upper is usage_lower == usage_upper' do record = build(:usage_charge, usage_lower: 100, usage_upper: 100, From 9d73b040d9668cf5493b35a55b5fd546e91fae69 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sat, 4 Oct 2025 21:40:59 +0900 Subject: [PATCH 29/37] feat: add uniqueness validation for ampere in BasicFee and overlapping range validation in UsageCharge --- .../challenge/app/models/basic_fee.rb | 1 + .../challenge/app/models/usage_charge.rb | 18 +++ .../challenge/spec/models/basic_fee_spec.rb | 26 ++++ .../spec/models/usage_charge_spec.rb | 134 +++++++++++++++++- 4 files changed, 178 insertions(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb index 0e095e006..c5fa85ec3 100644 --- a/serverside_challenge_2/challenge/app/models/basic_fee.rb +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -2,6 +2,7 @@ class BasicFee < ApplicationRecord belongs_to :plan validates :ampere, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :ampere, uniqueness: { scope: :plan_id } validates :fee, presence: true, numericality: { greater_than_or_equal_to: 0 } # @param ampere [Integer] 契約アンペア数(A) diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index 37ce53f8b..017d8134a 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -4,6 +4,7 @@ class UsageCharge < ApplicationRecord validates :unit_price, presence: true, numericality: { greater_than_or_equal_to: 0 } validates :usage_lower, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :usage_upper, numericality: { only_integer: true, greater_than: :usage_lower }, allow_nil: true + validate :no_overlapping_ranges # @param usage [Integer] 電気使用量(kWh) # NOTE: usage_upper = nullの場合は上限なしとみなす @@ -15,6 +16,23 @@ class UsageCharge < ApplicationRecord def calc_charge(usage) unit_price * usage end + + private + + # 同じ料金プラン内で、電気使用量の範囲が重複しないこと + def no_overlapping_ranges + max_usage = 2_147_483_647 # PostgreSQL INTEGER 最大値 + upper = usage_upper || max_usage + + overlapping = UsageCharge.where(plan_id:).where.not(id:) + .where("(usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?))", + upper, + usage_lower) + + return unless overlapping.exists? + + errors.add(:base, "overlapping usage range exists") + end end # == Schema Information diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb index d3d0631db..444b63a81 100644 --- a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -10,6 +10,32 @@ it { is_expected.to validate_numericality_of(:ampere).only_integer.is_greater_than(0) } it { is_expected.to validate_presence_of(:fee) } it { is_expected.to validate_numericality_of(:fee).is_greater_than_or_equal_to(0) } + + describe 'uniqueness of plan_id and ampere combination' do + let(:plan) { create(:plan) } + let!(:existing_basic_fee) { create(:basic_fee, plan:, ampere: 30, fee: 1000) } + + context 'when combination already exists' do + it 'is invalid with duplicate plan_id and ampere' do + new_basic_fee = build(:basic_fee, plan:, ampere: 30, fee: 1500) + expect(new_basic_fee).not_to be_valid + expect(new_basic_fee.errors[:ampere]).to include('has already been taken') + end + end + + context 'when combination is unique' do + it 'is valid with different plan' do + other_plan = create(:plan) + new_basic_fee = build(:basic_fee, plan: other_plan, ampere: 30, fee: 1000) + expect(new_basic_fee).to be_valid + end + + it 'is valid with different ampere' do + new_basic_fee = build(:basic_fee, plan:, ampere: 40, fee: 1000) + expect(new_basic_fee).to be_valid + end + end + end end describe 'scopes' do diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index acea1074a..6e6301766 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -40,7 +40,139 @@ it { should validate_presence_of(:unit_price) } it { should validate_numericality_of(:unit_price).is_greater_than_or_equal_to(0) } it { should validate_presence_of(:usage_lower) } - # it { should validate_numericality_of(:usage_upper).only_integer.is_greater_than(:usage_lower).allow_nil } + + describe '#no_overlapping_ranges' do + let(:plan) { create(:plan) } + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: 200) } + + + shared_examples 'invalid' do + it '重複しているため無効であること' do + expect(new_charge).not_to be_valid + expect(new_charge.errors[:base]).to include('overlapping usage range exists') + end + end + + shared_examples 'valid' do + it '重複していないため有効であること' do + expect(new_charge).to be_valid + end + end + + context '下限が既存の範囲よりも小さく、上限が既存の下限より小さい場合(完全に離れている)' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 99) } + it_behaves_like 'valid' + end + + context '下限が既存の上限よりも大きい場合(完全に離れている)' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 201, usage_upper: 300) } + it_behaves_like 'valid' + end + + context '別のプランに属する場合' do + let(:new_charge) { build(:usage_charge, plan: create(:plan), usage_lower: 100, usage_upper: 200) } + it_behaves_like 'valid' + end + + context '完全に同じ範囲の場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 100, usage_upper: 200) } + it_behaves_like 'invalid' + end + + context '既存範囲の下端と重なる場合([50, 100])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 100) } + it_behaves_like 'invalid' + end + + context '既存範囲の下端を超えて部分的に重なる場合([50, 101])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 101) } + it_behaves_like 'invalid' + end + + context '既存範囲の上端と重なる場合([200, 250])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 200, usage_upper: 250) } + it_behaves_like 'invalid' + end + + context '既存範囲の上端を超えて部分的に重なる場合([199, 250])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 199, usage_upper: 250) } + it_behaves_like 'invalid' + end + + context '既存範囲の中に完全に含まれる場合([120, 180])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 120, usage_upper: 180) } + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil(上限なし)の場合' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: nil) } + + context '下限・上限ともに既存範囲より小さい場合(重複なし)' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 99) } + it_behaves_like 'valid' + end + + context '既存範囲の下端と一致する場合([50, 100])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 100) } + it_behaves_like 'invalid' + end + + context '下限が既存範囲内にある場合([101, 1000])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 101, usage_upper: 1000) } + it_behaves_like 'invalid' + end + + context '上限が存在せず下限が既存範囲より小さい場合([99, nil])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 99, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '上限が存在せず下限が一致する場合([100, nil])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 100, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '上限が存在せず下限が既存範囲より少し大きい場合([101, nil])' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 101, usage_upper: nil) } + it_behaves_like 'invalid' + end + end + + context '新しい usage_upper が nil(上限なし)の場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 150, usage_upper: nil) } + + context '既存範囲が完全に下側にある場合([100, 149])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: 149) } + it_behaves_like 'valid' + end + + context '下限が既存範囲の下端と一致する場合([150, 151])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 150, usage_upper: 151) } + it_behaves_like 'invalid' + end + + context '下限が既存範囲のすぐ上にある場合([151, 152])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 151, usage_upper: 152) } + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil で範囲が重なる場合([149, nil])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 149, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil で下限が一致する場合([150, nil])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 150, usage_upper: nil) } + it_behaves_like 'invalid' + end + + context '既存の usage_upper が nil で下限が既存範囲より大きい場合([151, nil])' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 151, usage_upper: nil) } + it_behaves_like 'invalid' + end + end + end + describe 'usage_upper is greater than usage_lower' do it 'is valid if usage_lower < usage_upper' do record = build(:usage_charge, From a820c4f2d40e5fbc2eebd16ba8d34f6e76a27f93 Mon Sep 17 00:00:00 2001 From: shigenius Date: Sat, 4 Oct 2025 21:45:32 +0900 Subject: [PATCH 30/37] fix: remove unnecessary backslash in usage_charge_spec --- .../challenge/spec/models/usage_charge_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index 6e6301766..a69d43b51 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -18,7 +18,7 @@ let!(:usage_charge_4) { create(:usage_charge, usage_lower: 0, usage_upper: 99) } let!(:usage_charge_5) { create(:usage_charge, usage_lower: 101, usage_upper: 200) } let!(:usage_charge_6) { create(:usage_charge, usage_lower: 101, usage_upper: nil) } -\ + it 'returns usage charges that include the specified usage' do expect(subject).to contain_exactly(usage_charge_1, usage_charge_2, usage_charge_3) end From 5518ac936c94b4651d3b8229e07cd16f8a7cc229 Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 6 Oct 2025 22:46:15 +0900 Subject: [PATCH 31/37] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AATODO=E3=82=B3?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serverside_challenge_2/challenge/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index a70bd2739..e503fc744 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -50,7 +50,7 @@ def self.plan_prices(ampere:, usage:) def self.calc_price(basic_fee:, usage_charge:, usage:) fee = basic_fee.fee charge = usage_charge.calc_charge(usage) - (fee + charge).floor # TODO: 小数点以下の扱い + (fee + charge).floor end end From 094eb4da6f0db4c9fe16289e02e39ae25c55bec6 Mon Sep 17 00:00:00 2001 From: shigenius Date: Mon, 6 Oct 2025 23:36:53 +0900 Subject: [PATCH 32/37] =?UTF-8?q?pk,fk=E3=82=92code=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../challenge/app/models/basic_fee.rb | 15 +++--- .../challenge/app/models/plan.rb | 22 ++++---- .../challenge/app/models/provider.rb | 7 ++- .../challenge/app/models/usage_charge.rb | 15 +++--- .../20250928065553_create_providers.rb | 3 ++ .../db/migrate/20250928070016_create_plans.rb | 7 ++- .../20250928070125_create_basic_fees.rb | 7 ++- .../20250928070132_create_usage_charges.rb | 7 ++- serverside_challenge_2/challenge/db/schema.rb | 28 ++++++---- serverside_challenge_2/challenge/db/seeds.rb | 42 +++++---------- .../challenge/db/seeds/basic_fees.csv | 52 +++++++++---------- .../challenge/db/seeds/plans.csv | 10 ++-- .../challenge/db/seeds/providers.csv | 8 +-- .../challenge/db/seeds/usage_charges.csv | 22 ++++---- .../challenge/spec/factories/basic_fees.rb | 12 +++-- .../challenge/spec/factories/plans.rb | 17 +++--- .../challenge/spec/factories/providers.rb | 6 +++ .../challenge/spec/factories/usage_charges.rb | 12 +++-- .../challenge/spec/models/basic_fee_spec.rb | 15 +++--- .../challenge/spec/models/plan_spec.rb | 16 +++--- .../challenge/spec/models/provider_spec.rb | 5 ++ .../spec/models/usage_charge_spec.rb | 11 ++-- 22 files changed, 187 insertions(+), 152 deletions(-) diff --git a/serverside_challenge_2/challenge/app/models/basic_fee.rb b/serverside_challenge_2/challenge/app/models/basic_fee.rb index c5fa85ec3..6b5da3987 100644 --- a/serverside_challenge_2/challenge/app/models/basic_fee.rb +++ b/serverside_challenge_2/challenge/app/models/basic_fee.rb @@ -1,8 +1,8 @@ class BasicFee < ApplicationRecord - belongs_to :plan + belongs_to :plan, primary_key: :code, foreign_key: :plan_code validates :ampere, presence: true, numericality: { only_integer: true, greater_than: 0 } - validates :ampere, uniqueness: { scope: :plan_id } + validates :ampere, uniqueness: { scope: :plan_code } validates :fee, presence: true, numericality: { greater_than_or_equal_to: 0 } # @param ampere [Integer] 契約アンペア数(A) @@ -15,18 +15,19 @@ class BasicFee < ApplicationRecord # # id :bigint not null, primary key # ampere(契約アンペア数(A)) :integer not null +# code :string not null # fee(基本料金(円)) :decimal(10, 2) not null +# plan_code :string not null # created_at :datetime not null # updated_at :datetime not null -# plan_id :bigint not null # # Indexes # -# index_basic_fees_on_ampere (ampere) -# index_basic_fees_on_plan_id (plan_id) -# index_basic_fees_on_plan_id_and_ampere (plan_id,ampere) UNIQUE +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_code (code) UNIQUE +# index_basic_fees_on_plan_code_and_ampere (plan_code,ampere) UNIQUE # # Foreign Keys # -# fk_rails_... (plan_id => plans.id) +# fk_rails_... (plan_code => plans.code) # diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index e503fc744..a06a6071b 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan < ApplicationRecord - belongs_to :provider - has_many :basic_fees, dependent: :destroy - has_many :usage_charges, dependent: :destroy + belongs_to :provider, primary_key: :code, foreign_key: :provider_code + has_many :basic_fees, dependent: :destroy, primary_key: :code, foreign_key: :plan_code + has_many :usage_charges, dependent: :destroy, primary_key: :code, foreign_key: :plan_code validates :name, presence: true @@ -58,17 +58,19 @@ def self.calc_price(basic_fee:, usage_charge:, usage:) # # Table name: plans # -# id :bigint not null, primary key -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# provider_id :bigint not null +# id :bigint not null, primary key +# code :string not null +# name :string not null +# provider_code :string not null +# created_at :datetime not null +# updated_at :datetime not null # # Indexes # -# index_plans_on_provider_id (provider_id) +# index_plans_on_code (code) UNIQUE +# index_plans_on_provider_code (provider_code) # # Foreign Keys # -# fk_rails_... (provider_id => providers.id) +# fk_rails_... (provider_code => providers.code) # diff --git a/serverside_challenge_2/challenge/app/models/provider.rb b/serverside_challenge_2/challenge/app/models/provider.rb index cf2b83653..ec42422d8 100644 --- a/serverside_challenge_2/challenge/app/models/provider.rb +++ b/serverside_challenge_2/challenge/app/models/provider.rb @@ -1,5 +1,5 @@ class Provider < ApplicationRecord - has_many :plans, dependent: :destroy + has_many :plans, dependent: :destroy, primary_key: :code, foreign_key: :provider_code validates :name, presence: true end @@ -9,7 +9,12 @@ class Provider < ApplicationRecord # Table name: providers # # id :bigint not null, primary key +# code :string not null # name :string not null # created_at :datetime not null # updated_at :datetime not null # +# Indexes +# +# index_providers_on_code (code) UNIQUE +# diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index 017d8134a..d5ec4744c 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -1,5 +1,5 @@ class UsageCharge < ApplicationRecord - belongs_to :plan + belongs_to :plan, primary_key: :code, foreign_key: :plan_code validates :unit_price, presence: true, numericality: { greater_than_or_equal_to: 0 } validates :usage_lower, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -24,7 +24,7 @@ def no_overlapping_ranges max_usage = 2_147_483_647 # PostgreSQL INTEGER 最大値 upper = usage_upper || max_usage - overlapping = UsageCharge.where(plan_id:).where.not(id:) + overlapping = UsageCharge.where(plan_code:).where.not(id:) .where("(usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?))", upper, usage_lower) @@ -40,20 +40,21 @@ def no_overlapping_ranges # Table name: usage_charges # # id :bigint not null, primary key +# code :string not null +# plan_code :string not null # unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null # usage_lower(電気使用量(kWh) 下限) :integer not null # usage_upper(電気使用量(kWh) 上限) :integer # created_at :datetime not null # updated_at :datetime not null -# plan_id :bigint not null # # Indexes # -# index_usage_charges_on_plan_id (plan_id) -# index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper (plan_id,usage_lower,usage_upper) -# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# index_usage_charges_on_code (code) UNIQUE +# index_usage_charges_on_plan_code_and_usage_range (plan_code,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) # # Foreign Keys # -# fk_rails_... (plan_id => plans.id) +# fk_rails_... (plan_code => plans.code) # diff --git a/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb index 92200bf67..56ab4f2ca 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928065553_create_providers.rb @@ -1,8 +1,11 @@ class CreateProviders < ActiveRecord::Migration[7.0] def change create_table :providers, comment: '電力会社' do |t| + t.string :code, null: false t.string :name, null: false t.timestamps end + + add_index :providers, :code, unique: true end end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb index 264fb3770..89f0ccf61 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070016_create_plans.rb @@ -1,9 +1,14 @@ class CreatePlans < ActiveRecord::Migration[7.0] def change create_table :plans, comment: 'プラン' do |t| - t.references :provider, null: false, foreign_key: true + t.string :code, null: false + t.string :provider_code, null: false t.string :name, null: false t.timestamps end + + add_index :plans, :code, unique: true + add_index :plans, :provider_code + add_foreign_key :plans, :providers, column: :provider_code, primary_key: :code end end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb index 06bec2003..33caaf00e 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070125_create_basic_fees.rb @@ -1,13 +1,16 @@ class CreateBasicFees < ActiveRecord::Migration[7.0] def change create_table :basic_fees, comment: '基本料金' do |t| - t.references :plan, null: false, foreign_key: true + t.string :code, null: false + t.string :plan_code, null: false t.integer :ampere, null: false, comment: "契約アンペア数(A)" t.decimal :fee, precision: 10, scale: 2, null: false, comment: "基本料金(円)" t.timestamps end - add_index :basic_fees, [:plan_id, :ampere], unique: true + add_index :basic_fees, :code, unique: true + add_index :basic_fees, [:plan_code, :ampere], unique: true add_index :basic_fees, :ampere + add_foreign_key :basic_fees, :plans, column: :plan_code, primary_key: :code end end diff --git a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb index ac47edc33..d73e32f14 100644 --- a/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb +++ b/serverside_challenge_2/challenge/db/migrate/20250928070132_create_usage_charges.rb @@ -1,14 +1,17 @@ class CreateUsageCharges < ActiveRecord::Migration[7.0] def change create_table :usage_charges, comment: '従量料金' do |t| - t.references :plan, null: false, foreign_key: true + t.string :code, null: false + t.string :plan_code, null: false t.integer :usage_lower, null: false, comment: "電気使用量(kWh) 下限" t.integer :usage_upper, comment: "電気使用量(kWh) 上限" t.decimal :unit_price, precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" t.timestamps end + add_index :usage_charges, :code, unique: true add_index :usage_charges, [:usage_lower, :usage_upper] - add_index :usage_charges, [:plan_id, :usage_lower, :usage_upper] + add_index :usage_charges, [:plan_code, :usage_lower, :usage_upper], name: 'index_usage_charges_on_plan_code_and_usage_range' + add_foreign_key :usage_charges, :plans, column: :plan_code, primary_key: :code end end diff --git a/serverside_challenge_2/challenge/db/schema.rb b/serverside_challenge_2/challenge/db/schema.rb index 0175a2507..5d3cad040 100644 --- a/serverside_challenge_2/challenge/db/schema.rb +++ b/serverside_challenge_2/challenge/db/schema.rb @@ -15,43 +15,49 @@ enable_extension "plpgsql" create_table "basic_fees", comment: "基本料金", force: :cascade do |t| - t.bigint "plan_id", null: false + t.string "code", null: false + t.string "plan_code", null: false t.integer "ampere", null: false, comment: "契約アンペア数(A)" t.decimal "fee", precision: 10, scale: 2, null: false, comment: "基本料金(円)" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["ampere"], name: "index_basic_fees_on_ampere" - t.index ["plan_id", "ampere"], name: "index_basic_fees_on_plan_id_and_ampere", unique: true - t.index ["plan_id"], name: "index_basic_fees_on_plan_id" + t.index ["code"], name: "index_basic_fees_on_code", unique: true + t.index ["plan_code", "ampere"], name: "index_basic_fees_on_plan_code_and_ampere", unique: true end create_table "plans", comment: "プラン", force: :cascade do |t| - t.bigint "provider_id", null: false + t.string "code", null: false + t.string "provider_code", null: false t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["provider_id"], name: "index_plans_on_provider_id" + t.index ["code"], name: "index_plans_on_code", unique: true + t.index ["provider_code"], name: "index_plans_on_provider_code" end create_table "providers", comment: "電力会社", force: :cascade do |t| + t.string "code", null: false t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["code"], name: "index_providers_on_code", unique: true end create_table "usage_charges", comment: "従量料金", force: :cascade do |t| - t.bigint "plan_id", null: false + t.string "code", null: false + t.string "plan_code", null: false t.integer "usage_lower", null: false, comment: "電気使用量(kWh) 下限" t.integer "usage_upper", comment: "電気使用量(kWh) 上限" t.decimal "unit_price", precision: 10, scale: 2, null: false, comment: "従量料金単価(円/kWh)" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["plan_id", "usage_lower", "usage_upper"], name: "index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper" - t.index ["plan_id"], name: "index_usage_charges_on_plan_id" + t.index ["code"], name: "index_usage_charges_on_code", unique: true + t.index ["plan_code", "usage_lower", "usage_upper"], name: "index_usage_charges_on_plan_code_and_usage_range" t.index ["usage_lower", "usage_upper"], name: "index_usage_charges_on_usage_lower_and_usage_upper" end - add_foreign_key "basic_fees", "plans" - add_foreign_key "plans", "providers" - add_foreign_key "usage_charges", "plans" + add_foreign_key "basic_fees", "plans", column: "plan_code", primary_key: "code" + add_foreign_key "plans", "providers", column: "provider_code", primary_key: "code" + add_foreign_key "usage_charges", "plans", column: "plan_code", primary_key: "code" end diff --git a/serverside_challenge_2/challenge/db/seeds.rb b/serverside_challenge_2/challenge/db/seeds.rb index ba6a344a5..dcb7aa8bd 100644 --- a/serverside_challenge_2/challenge/db/seeds.rb +++ b/serverside_challenge_2/challenge/db/seeds.rb @@ -9,34 +9,18 @@ require 'csv' csv_dir = Rails.root.join("db", "seeds") -CSV.foreach(csv_dir.join("providers.csv"), headers: true) do |row| - Provider.create!( - id: row["id"], - name: row["name"], - ) -end - -CSV.foreach(csv_dir.join("plans.csv"), headers: true) do |row| - Plan.create!( - id: row["id"], - provider_id: row["provider_id"], - name: row["name"], - ) -end +seeds = [ + { file: "providers.csv", model: Provider, attributes: %w(code name) }, + { file: "plans.csv", model: Plan, attributes: %w(code provider_code name) }, + { file: "basic_fees.csv", model: BasicFee, attributes: %w(code plan_code ampere fee) }, + { file: "usage_charges.csv", model: UsageCharge, attributes: %w(code plan_code usage_lower usage_upper unit_price) } +] -CSV.foreach(csv_dir.join("basic_fees.csv"), headers: true) do |row| - BasicFee.create!( - plan_id: row["plan_id"], - ampere: row["ampere"], - fee: row["fee"], - ) +ActiveRecord::Base.transaction do + seeds.each do |seed| + CSV.foreach(csv_dir.join(seed[:file]), headers: true) do |row| + attributes = seed[:attributes].map { |attr| [attr.to_sym, row[attr]] }.to_h + seed[:model].create!(attributes) + end + end end - -CSV.foreach(csv_dir.join("usage_charges.csv"), headers: true) do |row| - UsageCharge.create!( - plan_id: row["plan_id"], - usage_lower: row["usage_lower"], - usage_upper: row["usage_upper"], - unit_price: row["unit_price"], - ) -end \ No newline at end of file diff --git a/serverside_challenge_2/challenge/db/seeds/basic_fees.csv b/serverside_challenge_2/challenge/db/seeds/basic_fees.csv index 6240a9ca5..580fdbb40 100644 --- a/serverside_challenge_2/challenge/db/seeds/basic_fees.csv +++ b/serverside_challenge_2/challenge/db/seeds/basic_fees.csv @@ -1,26 +1,26 @@ -id,plan_id,ampere,fee -1,1,10,286.00 -2,1,15,429.00 -3,1,20,572.00 -4,1,30,858.00 -5,1,40,1144.00 -6,1,50,1430.00 -7,1,60,1716.00 -8,2,10,311.75 -9,2,15,467.63 -10,2,20,623.50 -11,2,30,935.25 -12,2,40,1247.00 -13,2,50,1558.75 -14,2,60,1870.50 -15,3,30,858.00 -16,3,40,1144.00 -17,3,50,1430.00 -18,3,60,1716.00 -19,4,10,0.00 -20,4,15,0.00 -21,4,20,0.00 -22,4,30,0.00 -23,4,40,0.00 -24,4,50,0.00 -25,4,60,0.00 +code,plan_code,ampere,fee +basic_fee_1,plan_1,10,286.00 +basic_fee_2,plan_1,15,429.00 +basic_fee_3,plan_1,20,572.00 +basic_fee_4,plan_1,30,858.00 +basic_fee_5,plan_1,40,1144.00 +basic_fee_6,plan_1,50,1430.00 +basic_fee_7,plan_1,60,1716.00 +basic_fee_8,plan_2,10,311.75 +basic_fee_9,plan_2,15,467.63 +basic_fee_10,plan_2,20,623.50 +basic_fee_11,plan_2,30,935.25 +basic_fee_12,plan_2,40,1247.00 +basic_fee_13,plan_2,50,1558.75 +basic_fee_14,plan_2,60,1870.50 +basic_fee_15,plan_3,30,858.00 +basic_fee_16,plan_3,40,1144.00 +basic_fee_17,plan_3,50,1430.00 +basic_fee_18,plan_3,60,1716.00 +basic_fee_19,plan_4,10,0.00 +basic_fee_20,plan_4,15,0.00 +basic_fee_21,plan_4,20,0.00 +basic_fee_22,plan_4,30,0.00 +basic_fee_23,plan_4,40,0.00 +basic_fee_24,plan_4,50,0.00 +basic_fee_25,plan_4,60,0.00 diff --git a/serverside_challenge_2/challenge/db/seeds/plans.csv b/serverside_challenge_2/challenge/db/seeds/plans.csv index 309d868d3..ae9fc439d 100644 --- a/serverside_challenge_2/challenge/db/seeds/plans.csv +++ b/serverside_challenge_2/challenge/db/seeds/plans.csv @@ -1,5 +1,5 @@ -id,provider_id,name -1,1,従量電灯B -2,1,スタンダードS -3,2,ずっとも電気1 -4,3,おうちプラン +code,provider_code,name +plan_1,provider_1,従量電灯B +plan_2,provider_1,スタンダードS +plan_3,provider_2,ずっとも電気1 +plan_4,provider_3,おうちプラン diff --git a/serverside_challenge_2/challenge/db/seeds/providers.csv b/serverside_challenge_2/challenge/db/seeds/providers.csv index 8c7fc59fa..cd6192b0a 100644 --- a/serverside_challenge_2/challenge/db/seeds/providers.csv +++ b/serverside_challenge_2/challenge/db/seeds/providers.csv @@ -1,4 +1,4 @@ -id,name -1,東京電力エナジーパートナー -2,東京ガス -3,Looopでんき +code,name +provider_1,東京電力エナジーパートナー +provider_2,東京ガス +provider_3,Looopでんき diff --git a/serverside_challenge_2/challenge/db/seeds/usage_charges.csv b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv index 558cc380e..26d6ee6dc 100644 --- a/serverside_challenge_2/challenge/db/seeds/usage_charges.csv +++ b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv @@ -1,11 +1,11 @@ -id,plan_id,usage_lower,usage_upper,unit_price -1,1,0,120,19.88 -2,1,121,300,26.48 -3,1,301,,30.57 -4,2,0,120,29.80 -5,2,121,300,36.40 -6,2,301,,40.49 -7,3,0,140,23.67 -8,3,141,350,23.88 -9,3,351,,26.41 -10,4,0,,28.8 +code,plan_code,usage_lower,usage_upper,unit_price +usage_charge_1,plan_1,0,120,19.88 +usage_charge_2,plan_1,121,300,26.48 +usage_charge_3,plan_1,301,,30.57 +usage_charge_4,plan_2,0,120,29.80 +usage_charge_5,plan_2,121,300,36.40 +usage_charge_6,plan_2,301,,40.49 +usage_charge_7,plan_3,0,140,23.67 +usage_charge_8,plan_3,141,350,23.88 +usage_charge_9,plan_3,351,,26.41 +usage_charge_10,plan_4,0,,28.8 diff --git a/serverside_challenge_2/challenge/spec/factories/basic_fees.rb b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb index b5429d0d2..134d5c497 100644 --- a/serverside_challenge_2/challenge/spec/factories/basic_fees.rb +++ b/serverside_challenge_2/challenge/spec/factories/basic_fees.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :basic_fee do plan + sequence(:code) { |n| "basic_fee_code_#{n}" } ampere { rand(10..60) } fee { rand(500.00..2000.00).round(2) } end @@ -12,18 +13,19 @@ # # id :bigint not null, primary key # ampere(契約アンペア数(A)) :integer not null +# code :string not null # fee(基本料金(円)) :decimal(10, 2) not null +# plan_code :string not null # created_at :datetime not null # updated_at :datetime not null -# plan_id :bigint not null # # Indexes # -# index_basic_fees_on_ampere (ampere) -# index_basic_fees_on_plan_id (plan_id) -# index_basic_fees_on_plan_id_and_ampere (plan_id,ampere) UNIQUE +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_code (code) UNIQUE +# index_basic_fees_on_plan_code_and_ampere (plan_code,ampere) UNIQUE # # Foreign Keys # -# fk_rails_... (plan_id => plans.id) +# fk_rails_... (plan_code => plans.code) # diff --git a/serverside_challenge_2/challenge/spec/factories/plans.rb b/serverside_challenge_2/challenge/spec/factories/plans.rb index 0e4a2b7ca..8469193b7 100644 --- a/serverside_challenge_2/challenge/spec/factories/plans.rb +++ b/serverside_challenge_2/challenge/spec/factories/plans.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :plan do provider + sequence(:code) { |n| "plan_code_#{n}" } sequence(:name) { |n| "Plan_#{n}" } end end @@ -9,17 +10,19 @@ # # Table name: plans # -# id :bigint not null, primary key -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# provider_id :bigint not null +# id :bigint not null, primary key +# code :string not null +# name :string not null +# provider_code :string not null +# created_at :datetime not null +# updated_at :datetime not null # # Indexes # -# index_plans_on_provider_id (provider_id) +# index_plans_on_code (code) UNIQUE +# index_plans_on_provider_code (provider_code) # # Foreign Keys # -# fk_rails_... (provider_id => providers.id) +# fk_rails_... (provider_code => providers.code) # diff --git a/serverside_challenge_2/challenge/spec/factories/providers.rb b/serverside_challenge_2/challenge/spec/factories/providers.rb index ffb48fdcf..4898ee9de 100644 --- a/serverside_challenge_2/challenge/spec/factories/providers.rb +++ b/serverside_challenge_2/challenge/spec/factories/providers.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :provider do + sequence(:code) { |n| "provider_code_#{n}" } sequence(:name) { |n| "Provider_#{n}" } end end @@ -9,7 +10,12 @@ # Table name: providers # # id :bigint not null, primary key +# code :string not null # name :string not null # created_at :datetime not null # updated_at :datetime not null # +# Indexes +# +# index_providers_on_code (code) UNIQUE +# diff --git a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb index 6d748f2be..f1b886d8d 100644 --- a/serverside_challenge_2/challenge/spec/factories/usage_charges.rb +++ b/serverside_challenge_2/challenge/spec/factories/usage_charges.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :usage_charge do plan + sequence(:code) { |n| "usage_charge_code_#{n}" } usage_lower { rand(0..100) } usage_upper { usage_lower + rand(1..100) } unit_price { rand(10.00..50.00).round(2) } @@ -12,20 +13,21 @@ # Table name: usage_charges # # id :bigint not null, primary key +# code :string not null +# plan_code :string not null # unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null # usage_lower(電気使用量(kWh) 下限) :integer not null # usage_upper(電気使用量(kWh) 上限) :integer # created_at :datetime not null # updated_at :datetime not null -# plan_id :bigint not null # # Indexes # -# index_usage_charges_on_plan_id (plan_id) -# index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper (plan_id,usage_lower,usage_upper) -# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# index_usage_charges_on_code (code) UNIQUE +# index_usage_charges_on_plan_code_and_usage_range (plan_code,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) # # Foreign Keys # -# fk_rails_... (plan_id => plans.id) +# fk_rails_... (plan_code => plans.code) # diff --git a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb index 444b63a81..559ad73aa 100644 --- a/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/basic_fee_spec.rb @@ -11,12 +11,12 @@ it { is_expected.to validate_presence_of(:fee) } it { is_expected.to validate_numericality_of(:fee).is_greater_than_or_equal_to(0) } - describe 'uniqueness of plan_id and ampere combination' do + describe 'uniqueness of plan_code and ampere combination' do let(:plan) { create(:plan) } let!(:existing_basic_fee) { create(:basic_fee, plan:, ampere: 30, fee: 1000) } context 'when combination already exists' do - it 'is invalid with duplicate plan_id and ampere' do + it 'is invalid with duplicate plan_code and ampere' do new_basic_fee = build(:basic_fee, plan:, ampere: 30, fee: 1500) expect(new_basic_fee).not_to be_valid expect(new_basic_fee.errors[:ampere]).to include('has already been taken') @@ -61,18 +61,19 @@ # # id :bigint not null, primary key # ampere(契約アンペア数(A)) :integer not null +# code :string not null # fee(基本料金(円)) :decimal(10, 2) not null +# plan_code :string not null # created_at :datetime not null # updated_at :datetime not null -# plan_id :bigint not null # # Indexes # -# index_basic_fees_on_ampere (ampere) -# index_basic_fees_on_plan_id (plan_id) -# index_basic_fees_on_plan_id_and_ampere (plan_id,ampere) UNIQUE +# index_basic_fees_on_ampere (ampere) +# index_basic_fees_on_code (code) UNIQUE +# index_basic_fees_on_plan_code_and_ampere (plan_code,ampere) UNIQUE # # Foreign Keys # -# fk_rails_... (plan_id => plans.id) +# fk_rails_... (plan_code => plans.code) # diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index 11c66d327..cf899ea10 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -217,17 +217,19 @@ # # Table name: plans # -# id :bigint not null, primary key -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# provider_id :bigint not null +# id :bigint not null, primary key +# code :string not null +# name :string not null +# provider_code :string not null +# created_at :datetime not null +# updated_at :datetime not null # # Indexes # -# index_plans_on_provider_id (provider_id) +# index_plans_on_code (code) UNIQUE +# index_plans_on_provider_code (provider_code) # # Foreign Keys # -# fk_rails_... (provider_id => providers.id) +# fk_rails_... (provider_code => providers.code) # diff --git a/serverside_challenge_2/challenge/spec/models/provider_spec.rb b/serverside_challenge_2/challenge/spec/models/provider_spec.rb index 4b15cc47e..9d01c9b18 100644 --- a/serverside_challenge_2/challenge/spec/models/provider_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/provider_spec.rb @@ -15,7 +15,12 @@ # Table name: providers # # id :bigint not null, primary key +# code :string not null # name :string not null # created_at :datetime not null # updated_at :datetime not null # +# Indexes +# +# index_providers_on_code (code) UNIQUE +# diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index a69d43b51..a79b9c10e 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -209,20 +209,21 @@ # Table name: usage_charges # # id :bigint not null, primary key +# code :string not null +# plan_code :string not null # unit_price(従量料金単価(円/kWh)) :decimal(10, 2) not null # usage_lower(電気使用量(kWh) 下限) :integer not null # usage_upper(電気使用量(kWh) 上限) :integer # created_at :datetime not null # updated_at :datetime not null -# plan_id :bigint not null # # Indexes # -# index_usage_charges_on_plan_id (plan_id) -# index_usage_charges_on_plan_id_and_usage_lower_and_usage_upper (plan_id,usage_lower,usage_upper) -# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) +# index_usage_charges_on_code (code) UNIQUE +# index_usage_charges_on_plan_code_and_usage_range (plan_code,usage_lower,usage_upper) +# index_usage_charges_on_usage_lower_and_usage_upper (usage_lower,usage_upper) # # Foreign Keys # -# fk_rails_... (plan_id => plans.id) +# fk_rails_... (plan_code => plans.code) # From e6f0a8b95aa58a7e3b10fad4b7e1e549eba12855 Mon Sep 17 00:00:00 2001 From: shigenius Date: Wed, 8 Oct 2025 00:42:01 +0900 Subject: [PATCH 33/37] =?UTF-8?q?fix:=20=E5=BE=93=E9=87=8F=E6=96=99?= =?UTF-8?q?=E9=87=91=E3=81=AE=E8=A8=88=E7=AE=97=E3=83=AD=E3=82=B8=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=92=E7=B4=AF=E7=A9=8D=E8=A8=88=E7=AE=97=E3=81=AB?= =?UTF-8?q?=E3=80=82=E3=81=BE=E3=81=9Fcharge=5Fusages=E3=81=AElower=5Fusag?= =?UTF-8?q?e=E3=81=AE=E5=AE=9A=E7=BE=A9=E3=82=92=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../challenge/app/models/plan.rb | 14 +- .../challenge/app/models/usage_charge.rb | 14 +- .../challenge/db/seeds/usage_charges.csv | 12 +- .../challenge/spec/models/plan_spec.rb | 134 ++++++++------ .../spec/models/usage_charge_spec.rb | 164 +++++++++++++----- 5 files changed, 217 insertions(+), 121 deletions(-) diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index a06a6071b..bc521c792 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -13,7 +13,7 @@ class Plan < ApplicationRecord joins(:basic_fees, :usage_charges) .includes(:provider, :basic_fees, :usage_charges) .merge(BasicFee.by_ampere(ampere)) - .merge(UsageCharge.by_usage(usage)) + .merge(UsageCharge.lower_than(usage)) .distinct end @@ -28,12 +28,12 @@ def self.plan_prices(ampere:, usage:) plans = self.by_ampere_and_usage(ampere, usage) plans.map do |plan| basic_fee = plan.basic_fees.first - usage_charge = plan.usage_charges.first + usage_charges = plan.usage_charges { provider_name: plan.provider.name, plan_name: plan.name, - price: self.calc_price(basic_fee:, usage_charge:, usage:), + price: self.calc_price(basic_fee:, usage_charges:, usage:), } end end @@ -44,13 +44,13 @@ def self.plan_prices(ampere:, usage:) # NOTE: 現時点では③そのほかは考慮しない # NOTE: もし他の箇所で同じように料金を計算する必要が出てきた場合や、そのほかを計算し複雑化する場合は、電気料金の値オブジェクト化を検討すること # @param basic_fee [BasicFee] 基本料金オブジェクト - # @param usage_charge [UsageCharge] 従量料金オブジェクト + # @param usage_charges [Array] 従量料金オブジェクトの配列 # @param usage [Integer] 電気使用量(kWh) # @return [Integer] 合計電気料金 (円) - def self.calc_price(basic_fee:, usage_charge:, usage:) + def self.calc_price(basic_fee:, usage_charges:, usage:) fee = basic_fee.fee - charge = usage_charge.calc_charge(usage) - (fee + charge).floor + total_charge = usage_charges.sum { |uc| uc.calc_charge(usage) } + (fee + total_charge).floor end end diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index d5ec4744c..5cade7845 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -7,25 +7,31 @@ class UsageCharge < ApplicationRecord validate :no_overlapping_ranges # @param usage [Integer] 電気使用量(kWh) - # NOTE: usage_upper = nullの場合は上限なしとみなす - scope :by_usage, ->(usage) { where("usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?)", usage, usage) } + scope :lower_than, ->(usage) { where("usage_lower <= ?", usage) } # 従量料金 = 従量料金単価(円/kWh) × 電気使用量(kWh) + # ※ 計算で用いる 電気使用量(kWh) は、usage_lower以上、usage_upper以下の範囲内の使用量 # @param usage [Integer] 電気使用量(kWh) # @return [BigDecimal] 従量料金(円) def calc_charge(usage) - unit_price * usage + return 0.0 if usage_lower > usage + + upper = usage_upper.present? && usage_upper < usage ? usage_upper : usage + applicable_usage = upper - usage_lower + unit_price * applicable_usage end private # 同じ料金プラン内で、電気使用量の範囲が重複しないこと + # upper_usageと次のlower_usageが重複しても良い (例: 0-120, 120-300) + # NOTE: usage_upper = nullの場合は上限なしとみなす def no_overlapping_ranges max_usage = 2_147_483_647 # PostgreSQL INTEGER 最大値 upper = usage_upper || max_usage overlapping = UsageCharge.where(plan_code:).where.not(id:) - .where("(usage_lower <= ? AND (usage_upper IS NULL OR usage_upper >= ?))", + .where("(usage_lower < ? AND (usage_upper IS NULL OR usage_upper > ?))", upper, usage_lower) diff --git a/serverside_challenge_2/challenge/db/seeds/usage_charges.csv b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv index 26d6ee6dc..07f36477e 100644 --- a/serverside_challenge_2/challenge/db/seeds/usage_charges.csv +++ b/serverside_challenge_2/challenge/db/seeds/usage_charges.csv @@ -1,11 +1,11 @@ code,plan_code,usage_lower,usage_upper,unit_price usage_charge_1,plan_1,0,120,19.88 -usage_charge_2,plan_1,121,300,26.48 -usage_charge_3,plan_1,301,,30.57 +usage_charge_2,plan_1,120,300,26.48 +usage_charge_3,plan_1,300,,30.57 usage_charge_4,plan_2,0,120,29.80 -usage_charge_5,plan_2,121,300,36.40 -usage_charge_6,plan_2,301,,40.49 +usage_charge_5,plan_2,120,300,36.40 +usage_charge_6,plan_2,300,,40.49 usage_charge_7,plan_3,0,140,23.67 -usage_charge_8,plan_3,141,350,23.88 -usage_charge_9,plan_3,351,,26.41 +usage_charge_8,plan_3,140,350,23.88 +usage_charge_9,plan_3,350,,26.41 usage_charge_10,plan_4,0,,28.8 diff --git a/serverside_challenge_2/challenge/spec/models/plan_spec.rb b/serverside_challenge_2/challenge/spec/models/plan_spec.rb index cf899ea10..ac8441fa9 100644 --- a/serverside_challenge_2/challenge/spec/models/plan_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/plan_spec.rb @@ -12,29 +12,44 @@ subject { described_class.by_ampere_and_usage(ampere, usage) } let(:plan) { create(:plan) } - let!(:basic_fee) { create(:basic_fee, plan:, ampere: 30) } - let!(:usage_charge) { create(:usage_charge, plan:, usage_lower: 10, usage_upper: 100) } - context 'アンペアと使用量に該当するプランがある場合' do - let(:ampere) { basic_fee.ampere } - let(:usage) { usage_charge.usage_lower } + context 'アンペアと使用量に該当するものがある場合/usage_chargesが一部ヒットする場合' do + let(:ampere) { 30 } + let(:usage) { 100 } + + let!(:basic_fee) { create(:basic_fee, plan:, ampere:) } + let!(:usage_charge1) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: usage) } + let!(:usage_charge2) { create(:usage_charge, plan:, usage_lower: usage, usage_upper: usage + 1) } + let!(:usage_charge3) { create(:usage_charge, plan:, usage_lower: usage + 1, usage_upper: usage + 2) } # not included + + it 'returns target plans and selected associations' do + is_expected.to contain_exactly(plan) - it 'returns target plans' do - is_expected.to include(plan) + subject.first.tap do |p| + expect(p.basic_fees).to contain_exactly(basic_fee) + expect(p.usage_charges).to contain_exactly(usage_charge1, usage_charge2) # 正しく絞り込まれた状態で関連を取得できていること + end end end context 'アンペアに該当するものがない場合' do - let(:ampere) { basic_fee.ampere - 1 } - let(:usage) { usage_charge.usage_lower } + let(:ampere) { 30 } + let(:usage) { 100 } + + let!(:basic_fee) { create(:basic_fee, plan:, ampere: ampere + 1) } + let!(:usage_charge) { create(:usage_charge, plan:, usage_lower: usage, usage_upper: usage + 1) } it 'returns an empty' do is_expected.to be_empty end end + context '使用料に該当するものがない場合' do - let(:ampere) { basic_fee.ampere } - let(:usage) { usage_charge.usage_lower - 1 } + let(:ampere) { 30 } + let(:usage) { 100 } + + let!(:basic_fee) { create(:basic_fee, plan:, ampere:) } + let!(:usage_charge) { create(:usage_charge, plan:, usage_lower: usage + 1, usage_upper: nil) } it 'returns an empty' do is_expected.to be_empty @@ -54,26 +69,40 @@ provider1 = create(:provider, name: '東京電力エナジーパートナー') provider2 = create(:provider, name: '東京ガス') provider3 = create(:provider, name: 'Looopでんき') - plan1 = create(:plan, name: '従量電灯B', provider: provider1) + + plan1 = create(:plan, name: '従量電灯B', provider: provider1) plan2 = create(:plan, name: 'スタンダードS', provider: provider1) plan3 = create(:plan, name: 'ずっとも電気1', provider: provider2) - plan4 = create(:plan, name: 'おうちプラン', provider: provider3) + plan4 = create(:plan, name: 'おうちプラン', provider: provider3) + create(:basic_fee, plan: plan1, ampere: 10, fee: 286.0) create(:basic_fee, plan: plan1, ampere: 30, fee: 858.00) + create(:basic_fee, plan: plan1, ampere: 40, fee: 1144.00) + create(:basic_fee, plan: plan2, ampere: 10, fee: 311.75) create(:basic_fee, plan: plan2, ampere: 30, fee: 935.25) + create(:basic_fee, plan: plan2, ampere: 40, fee: 1247.00) + + # plan3は10Aの基本料金がないパターン create(:basic_fee, plan: plan3, ampere: 30, fee: 858.00) + create(:basic_fee, plan: plan3, ampere: 40, fee: 1144.00) + create(:basic_fee, plan: plan4, ampere: 10, fee: 0.00) create(:basic_fee, plan: plan4, ampere: 30, fee: 0.00) + create(:basic_fee, plan: plan4, ampere: 40, fee: 0.00) + create(:usage_charge, plan: plan1, usage_lower: 0, usage_upper: 120, unit_price: 19.88) - create(:usage_charge, plan: plan1, usage_lower: 121, usage_upper: 300, unit_price: 26.48) - create(:usage_charge, plan: plan1, usage_lower: 301, usage_upper: nil, unit_price: 30.57) + create(:usage_charge, plan: plan1, usage_lower: 120, usage_upper: 300, unit_price: 26.48) + create(:usage_charge, plan: plan1, usage_lower: 300, usage_upper: nil, unit_price: 30.57) + create(:usage_charge, plan: plan2, usage_lower: 0, usage_upper: 120, unit_price: 29.80) - create(:usage_charge, plan: plan2, usage_lower: 121, usage_upper: 300, unit_price: 36.40) - create(:usage_charge, plan: plan2, usage_lower: 301, usage_upper: nil, unit_price: 40.49) + create(:usage_charge, plan: plan2, usage_lower: 120, usage_upper: 300, unit_price: 36.40) + create(:usage_charge, plan: plan2, usage_lower: 300, usage_upper: nil, unit_price: 40.49) + create(:usage_charge, plan: plan3, usage_lower: 0, usage_upper: 120, unit_price: 23.67) - create(:usage_charge, plan: plan3, usage_lower: 121, usage_upper: 300, unit_price: 23.88) - create(:usage_charge, plan: plan3, usage_lower: 301, usage_upper: nil, unit_price: 26.41) + create(:usage_charge, plan: plan3, usage_lower: 120, usage_upper: 300, unit_price: 23.88) + create(:usage_charge, plan: plan3, usage_lower: 300, usage_upper: nil, unit_price: 26.41) + create(:usage_charge, plan: plan4, usage_lower: 0, usage_upper: nil, unit_price: 28.8) end @@ -83,9 +112,9 @@ it 'returns correct plan prices' do is_expected.to match_array([ - { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 2671 }, - { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 3887 }, - { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 2671 }, # 286 + (19.88 * 120) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 3887 }, # 311.75 + (29.80 * 120) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, # 0 + (28.8 * 120) ]) end end @@ -96,24 +125,25 @@ it 'returns correct plan prices' do is_expected.to match_array([ - { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3243 }, - { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4511 }, - { provider_name: '東京ガス', plan_name: 'ずっとも電気1', price: 3698 }, - { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3243 }, # 858 + (19.88 * 120) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4511 }, # 935.25 + (29.80 * 120) + { provider_name: '東京ガス', plan_name: 'ずっとも電気1', price: 3698 }, # 858 + (23.67 * 120) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3456 }, # 0 + (28.8 * 120) ]) end end - context 'アンペア10A、使用量121kWhの場合' do + # ※ ひとつのplanに対して、複数のusage_chargeが該当するパターン + # 使用量の計算は段階的に計算されること let(:ampere) { 10 } let(:usage) { 121 } it 'returns correct plan prices' do is_expected.to match_array([ - { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3490 }, - { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4716}, - { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3484 }, + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 2698 }, # 286 + (19.88 * 120) + (26.48 * 1) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 3924 }, # 311.75 + (29.80 * 120) + (36.40 * 1) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3484 }, # 0 + (28.8 * 121) ]) end end @@ -124,9 +154,23 @@ it 'returns correct plan prices' do is_expected.to match_array([ - { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 9518 }, - { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 12539 }, - { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 8697}, + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 7499 }, # 286 + (19.88 * 120) + (26.48 * 180) + (30.57 * 2) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 10520 }, # 311.75 + (29.80 * 120) + (36.40 * 180) + (40.49 * 2) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 8697 }, # 0 + (28.8 * 302) + ]) + end + end + + context 'アンペア40A、使用量121kWhの場合' do + let(:ampere) { 40 } + let(:usage) { 121 } + + it 'returns correct plan prices' do + is_expected.to match_array([ + { provider_name: '東京電力エナジーパートナー', plan_name: '従量電灯B', price: 3556 }, # 1144 + (19.88 * 120) + (26.48 * 1) + { provider_name: '東京電力エナジーパートナー', plan_name: 'スタンダードS', price: 4859 }, # 1247.00 + (29.80 * 120) + (36.40 * 1) + { provider_name: '東京ガス', plan_name: 'ずっとも電気1', price: 4008 }, # 1144 + (23.67 * 120) + (23.88 * 1) + { provider_name: 'Looopでんき', plan_name: 'おうちプラン', price: 3484}, # 0 + (28.8 * 121) ]) end end @@ -187,30 +231,6 @@ end end end - - describe '.calc_price' do - subject { described_class.calc_price(basic_fee:, usage_charge:, usage:) } - - context '計算結果の小数点が0.5以上の場合' do - let(:basic_fee) { create(:basic_fee, fee: 311.75) } - let(:usage_charge) { create(:usage_charge, unit_price: 40.49) } - let(:usage) { 351 } - - it '計算結果が正しいこと。小数点以下切り捨てであること' do - expect(subject).to eq(14523) - end - end - - context '計算結果の小数点が0.5未満の場合' do - let(:basic_fee) { create(:basic_fee, fee: 100.01) } - let(:usage_charge) { create(:usage_charge, unit_price: 10.01) } - let(:usage) { 10 } - - it '計算結果が正しいこと。小数点以下切り捨てであること' do - expect(subject).to eq(200) - end - end - end end # == Schema Information diff --git a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb index a79b9c10e..dc218ae0c 100644 --- a/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb +++ b/serverside_challenge_2/challenge/spec/models/usage_charge_spec.rb @@ -6,18 +6,15 @@ end describe 'scopes' do - describe '.by_usage' do - subject { described_class.by_usage(usage) } + describe '.lower_than' do + subject { described_class.lower_than(usage) } let(:usage) { 100 } - let!(:usage_charge_1) { create(:usage_charge, usage_lower: 0, usage_upper: 100) } - let!(:usage_charge_2) { create(:usage_charge, usage_lower: 100, usage_upper: 200) } - let!(:usage_charge_3) { create(:usage_charge, usage_lower: 100, usage_upper: nil) } - - let!(:usage_charge_4) { create(:usage_charge, usage_lower: 0, usage_upper: 99) } - let!(:usage_charge_5) { create(:usage_charge, usage_lower: 101, usage_upper: 200) } - let!(:usage_charge_6) { create(:usage_charge, usage_lower: 101, usage_upper: nil) } + let!(:usage_charge_1) { create(:usage_charge, usage_lower: 0) } + let!(:usage_charge_2) { create(:usage_charge, usage_lower: 99) } + let!(:usage_charge_3) { create(:usage_charge, usage_lower: 100) } + let!(:usage_charge_4) { create(:usage_charge, usage_lower: 101) } it 'returns usage charges that include the specified usage' do expect(subject).to contain_exactly(usage_charge_1, usage_charge_2, usage_charge_3) @@ -28,11 +25,76 @@ describe '#calc_charge' do subject { usage_charge.calc_charge(usage) } - let(:usage_charge) { build(:usage_charge, unit_price: 19.88) } - let(:usage) { 111 } + context '使用量が0kWhの場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let(:usage) { 0 } + + it '0を返すこと' do + expect(subject).to eq 0 + end + end + + context '第1段階内での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let(:usage) { 50 } + + it '期待される従量料金を返すこと' do + expect(subject).to eq 994.0 # 19.88 * 50 + end + end + + context '第1段階の上限を超える使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 0, usage_upper: 120, unit_price: 19.88) } + let(:usage) { 121 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(2385.6) # 19.88 * 120(上限) + end + end + + context '第2段階の境界値(下限-1)での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 119 } + + it '0を返すこと' do + expect(subject).to eq 0.0 + end + end + + context '第2段階の境界値(下限)での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 120 } + + it '0を返すこと' do + expect(subject).to eq 0.0 + end + end + + context '第2段階内での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 200 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(2118.4) # 26.48 * (200 - 120) + end + end + + context '第2段階の上限を超える使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 120, usage_upper: 300, unit_price: 26.48) } + let(:usage) { 301 } + + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(4766.4) # 26.48 * (300 - 120) + end + end + + context '上限なしの段階での使用量の場合' do + let(:usage_charge) { build(:usage_charge, usage_lower: 300, usage_upper: nil, unit_price: 26.48) } + let(:usage) { 350 } - it '期待される従量料金を返すこと' do - expect(subject).to be_within(0.01).of(2206.68) + it '期待される従量料金を返すこと' do + expect(subject).to be_within(0.01).of(1324) # 26.48 * (350 - 300) + end end end @@ -59,66 +121,71 @@ end end - context '下限が既存の範囲よりも小さく、上限が既存の下限より小さい場合(完全に離れている)' do + context '新規上限 = 既存下限 - 1' do let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 99) } it_behaves_like 'valid' end - context '下限が既存の上限よりも大きい場合(完全に離れている)' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 201, usage_upper: 300) } + context '新規上限 = 既存下限' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 100) } it_behaves_like 'valid' end - context '別のプランに属する場合' do - let(:new_charge) { build(:usage_charge, plan: create(:plan), usage_lower: 100, usage_upper: 200) } - it_behaves_like 'valid' + context '新規上限 = 既存下限 + 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 101) } + it_behaves_like 'invalid' end - context '完全に同じ範囲の場合' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 100, usage_upper: 200) } + context '新規下限 = 既存上限 - 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 199, usage_upper: 300) } it_behaves_like 'invalid' end - context '既存範囲の下端と重なる場合([50, 100])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 100) } - it_behaves_like 'invalid' + context '新規下限 = 既存上限' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 200, usage_upper: 300) } + it_behaves_like 'valid' end - context '既存範囲の下端を超えて部分的に重なる場合([50, 101])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 101) } - it_behaves_like 'invalid' + context '新規下限 = 既存上限 + 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 201, usage_upper: 300) } + it_behaves_like 'valid' end - context '既存範囲の上端と重なる場合([200, 250])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 200, usage_upper: 250) } + context '完全に同じ範囲の場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 100, usage_upper: 200) } it_behaves_like 'invalid' end - context '既存範囲の上端を超えて部分的に重なる場合([199, 250])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 199, usage_upper: 250) } + context '既存範囲の中に含まれる場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 101, usage_upper: 199) } it_behaves_like 'invalid' end - context '既存範囲の中に完全に含まれる場合([120, 180])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 120, usage_upper: 180) } + context '既存範囲の中に含む場合' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 99, usage_upper: 201) } it_behaves_like 'invalid' end + context '別のプランに属する場合' do + let(:new_charge) { build(:usage_charge, plan: create(:plan), usage_lower: 100, usage_upper: 200) } + it_behaves_like 'valid' + end + context '既存の usage_upper が nil(上限なし)の場合' do let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: nil) } - context '下限・上限ともに既存範囲より小さい場合(重複なし)' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 99) } + context '新規上限 = 既存下限 - 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 99) } it_behaves_like 'valid' end - context '既存範囲の下端と一致する場合([50, 100])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 50, usage_upper: 100) } - it_behaves_like 'invalid' + context '新規上限 = 既存下限' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 100) } + it_behaves_like 'valid' end - context '下限が既存範囲内にある場合([101, 1000])' do - let(:new_charge) { build(:usage_charge, plan:, usage_lower: 101, usage_upper: 1000) } + context '新規上限 = 既存下限 + 1' do + let(:new_charge) { build(:usage_charge, plan:, usage_lower: 0, usage_upper: 101) } it_behaves_like 'invalid' end @@ -141,18 +208,21 @@ context '新しい usage_upper が nil(上限なし)の場合' do let(:new_charge) { build(:usage_charge, plan:, usage_lower: 150, usage_upper: nil) } - context '既存範囲が完全に下側にある場合([100, 149])' do - let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 100, usage_upper: 149) } + context '新規上限 = 既存下限 - 1' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: 149) } + it_behaves_like 'valid' end - context '下限が既存範囲の下端と一致する場合([150, 151])' do - let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 150, usage_upper: 151) } - it_behaves_like 'invalid' + context '新規上限 = 既存下限' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: 150) } + + it_behaves_like 'valid' end - context '下限が既存範囲のすぐ上にある場合([151, 152])' do - let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 151, usage_upper: 152) } + context '新規上限 = 既存下限 + 1' do + let!(:existing_charge) { create(:usage_charge, plan:, usage_lower: 0, usage_upper: 151) } + it_behaves_like 'invalid' end From 8a3a83a0778162d13367932d80fb02fe7af08895 Mon Sep 17 00:00:00 2001 From: shigenius Date: Wed, 8 Oct 2025 00:42:07 +0900 Subject: [PATCH 34/37] update erd --- serverside_challenge_2/challenge/erd.pdf | Bin 33135 -> 36640 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/serverside_challenge_2/challenge/erd.pdf b/serverside_challenge_2/challenge/erd.pdf index 29d75629e91de49d9893ef4e6890482f6906206b..49416b4abd5280279881818518825dd5d1cce10e 100644 GIT binary patch delta 20296 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 SN6BIkG6YFzXl%E$lK3AXx7pAD delta 17211 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 Date: Wed, 8 Oct 2025 01:34:57 +0900 Subject: [PATCH 35/37] fix: change basic_fee assignment to use sole method for clarity --- serverside_challenge_2/challenge/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index bc521c792..ce86ad6b5 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -27,7 +27,7 @@ def self.plan_prices(ampere:, usage:) plans = self.by_ampere_and_usage(ampere, usage) plans.map do |plan| - basic_fee = plan.basic_fees.first + basic_fee = plan.basic_fees.sole # 基本料金は契約アンペア数に対して1つだけ存在する想定 usage_charges = plan.usage_charges { From 8aa50fc1b326222ad63fc8752bbf07230fd9f6af Mon Sep 17 00:00:00 2001 From: shigenius Date: Wed, 8 Oct 2025 01:40:37 +0900 Subject: [PATCH 36/37] add comment --- serverside_challenge_2/challenge/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverside_challenge_2/challenge/app/models/plan.rb b/serverside_challenge_2/challenge/app/models/plan.rb index ce86ad6b5..cc6589bd0 100644 --- a/serverside_challenge_2/challenge/app/models/plan.rb +++ b/serverside_challenge_2/challenge/app/models/plan.rb @@ -28,7 +28,7 @@ def self.plan_prices(ampere:, usage:) plans = self.by_ampere_and_usage(ampere, usage) plans.map do |plan| basic_fee = plan.basic_fees.sole # 基本料金は契約アンペア数に対して1つだけ存在する想定 - usage_charges = plan.usage_charges + usage_charges = plan.usage_charges # すでに絞り込まれている想定 { provider_name: plan.provider.name, From 80da434ef8fac018b13e6859265f044137e13596 Mon Sep 17 00:00:00 2001 From: shigenius Date: Wed, 8 Oct 2025 02:04:10 +0900 Subject: [PATCH 37/37] =?UTF-8?q?UsageCharge#calc=5Fcharge=E3=82=92?= =?UTF-8?q?=E5=B0=91=E3=81=97=E6=94=B9=E8=89=AF=E3=80=82=E3=81=82=E3=81=A8?= =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serverside_challenge_2/challenge/app/models/usage_charge.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/serverside_challenge_2/challenge/app/models/usage_charge.rb b/serverside_challenge_2/challenge/app/models/usage_charge.rb index 5cade7845..a9501ba8e 100644 --- a/serverside_challenge_2/challenge/app/models/usage_charge.rb +++ b/serverside_challenge_2/challenge/app/models/usage_charge.rb @@ -10,11 +10,11 @@ class UsageCharge < ApplicationRecord scope :lower_than, ->(usage) { where("usage_lower <= ?", usage) } # 従量料金 = 従量料金単価(円/kWh) × 電気使用量(kWh) - # ※ 計算で用いる 電気使用量(kWh) は、usage_lower以上、usage_upper以下の範囲内の使用量 + # ※ 計算で用いる 電気使用量(kWh) は、usage(またはusage_upper) - usage_lower で計算する # @param usage [Integer] 電気使用量(kWh) # @return [BigDecimal] 従量料金(円) def calc_charge(usage) - return 0.0 if usage_lower > usage + return 0.0 if usage_lower >= usage upper = usage_upper.present? && usage_upper < usage ? usage_upper : usage applicable_usage = upper - usage_lower
電力会社名${object.provider_name}${object.plan_name}${object.price}${plan.provider_name}${plan.plan_name}${plan.price}