From d18f9afd493c30364646cc1e1bdde53958add525 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 18:45:26 +0500 Subject: [PATCH 01/28] add customers table --- app/models/customer.rb | 2 ++ db/migrate/20261006134215_create_customers.rb | 14 ++++++++++ db/schema.rb | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 app/models/customer.rb create mode 100644 db/migrate/20261006134215_create_customers.rb create mode 100644 db/schema.rb diff --git a/app/models/customer.rb b/app/models/customer.rb new file mode 100644 index 0000000..0b52773 --- /dev/null +++ b/app/models/customer.rb @@ -0,0 +1,2 @@ +class Customer < ApplicationRecord +end diff --git a/db/migrate/20261006134215_create_customers.rb b/db/migrate/20261006134215_create_customers.rb new file mode 100644 index 0000000..fe99825 --- /dev/null +++ b/db/migrate/20261006134215_create_customers.rb @@ -0,0 +1,14 @@ +class CreateCustomers < ActiveRecord::Migration[8.1] + def change + create_table :customers do |t| + t.string :olist_customer_unique_id, null: false + t.string :name + t.string :email + t.string :city + t.string :state + + t.timestamps + end + add_index :customers, :olist_customer_unique_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..1069ca8 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,27 @@ +# 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[8.1].define(version: 2026_10_06_134215) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "customers", force: :cascade do |t| + t.string "olist_customer_unique_id", null: false + t.string "name" + t.string "email" + t.string "city" + t.string "state" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["olist_customer_unique_id"], name: "index_customers_on_olist_customer_unique_id", unique: true + end +end From 2d3e3cd20d3ee527fce35884a900e85b6cf27ef3 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 18:58:31 +0500 Subject: [PATCH 02/28] add products table --- app/models/product.rb | 2 ++ db/migrate/20261006135202_create_products.rb | 19 +++++++++++++++++++ db/schema.rb | 18 +++++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/models/product.rb create mode 100644 db/migrate/20261006135202_create_products.rb diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 0000000..35a85ac --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,2 @@ +class Product < ApplicationRecord +end diff --git a/db/migrate/20261006135202_create_products.rb b/db/migrate/20261006135202_create_products.rb new file mode 100644 index 0000000..b29eb38 --- /dev/null +++ b/db/migrate/20261006135202_create_products.rb @@ -0,0 +1,19 @@ +class CreateProducts < ActiveRecord::Migration[8.1] + def change + create_table :products do |t| + t.string :olist_product_id, null: false + t.string :name + t.string :category + t.integer :name_length + t.integer :description_length + t.integer :photos_count + t.integer :weight_g + t.integer :length_cm + t.integer :height_cm + t.integer :width_cm + + t.timestamps + end + add_index :products, :olist_product_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 1069ca8..2240d9d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_134215) do +ActiveRecord::Schema[8.1].define(version: 2026_10_06_135202) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -24,4 +24,20 @@ t.datetime "updated_at", null: false t.index ["olist_customer_unique_id"], name: "index_customers_on_olist_customer_unique_id", unique: true end + + create_table "products", force: :cascade do |t| + t.string "olist_product_id", null: false + t.string "name" + t.string "category" + t.integer "name_length" + t.integer "description_length" + t.integer "photos_count" + t.integer "weight_g" + t.integer "length_cm" + t.integer "height_cm" + t.integer "width_cm" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["olist_product_id"], name: "index_products_on_olist_product_id", unique: true + end end From 1178816abdbe6eb2b9f69f1600e9d56f4c8e1f44 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:02:37 +0500 Subject: [PATCH 03/28] add rspec, factory_bot, faker and csv gems --- Gemfile | 5 +++++ Gemfile.lock | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/Gemfile b/Gemfile index f1a90c4..8a6a570 100644 --- a/Gemfile +++ b/Gemfile @@ -54,6 +54,9 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false + gem "rspec-rails" + gem "factory_bot_rails" + gem "faker" end group :development do @@ -66,3 +69,5 @@ group :test do gem "capybara" gem "selenium-webdriver" end + +gem "csv" diff --git a/Gemfile.lock b/Gemfile.lock index ac99ec7..ff6ed30 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,10 +102,12 @@ GEM concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -113,6 +115,13 @@ GEM erubi (1.13.1) et-orbi (1.4.2) tzinfo + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 2) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -277,6 +286,23 @@ GEM regexp_parser (2.13.1) reline (0.7.0) io-console (~> 0.5) + rspec-core (3.13.6) + 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.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) rubocop (1.91.0) json (>= 2.3) language_server-protocol (~> 3.17.0.2) @@ -393,7 +419,10 @@ DEPENDENCIES brakeman bundler-audit capybara + csv debug + factory_bot_rails + faker image_processing (~> 1.2) importmap-rails jbuilder @@ -402,6 +431,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.4) + rspec-rails rubocop-rails-omakase selenium-webdriver solid_cable @@ -441,14 +471,19 @@ CHECKSUMS concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 @@ -520,6 +555,11 @@ CHECKSUMS rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce From d6d95e19633cd851193939ee2322ccaf194487e7 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:03:53 +0500 Subject: [PATCH 04/28] rspes scaffold generate --- .rspec | 1 + spec/rails_helper.rb | 72 +++++++++++++++++++++++++++++++++ spec/spec_helper.rb | 94 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 .rspec create mode 100644 spec/rails_helper.rb create mode 100644 spec/spec_helper.rb diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..ef75d46 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# 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? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +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 } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# 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_paths = [ + 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 uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # 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/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..327b58e --- /dev/null +++ b/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 80d9f4730835914a403ad4d85ed7a8e02168579f Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:04:19 +0500 Subject: [PATCH 05/28] remove default minitest folder --- test/controllers/.keep | 0 test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/test_helper.rb | 15 --------------- 7 files changed, 15 deletions(-) delete mode 100644 test/controllers/.keep delete mode 100644 test/fixtures/files/.keep delete mode 100644 test/helpers/.keep delete mode 100644 test/integration/.keep delete mode 100644 test/mailers/.keep delete mode 100644 test/models/.keep delete mode 100644 test/test_helper.rb diff --git a/test/controllers/.keep b/test/controllers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/helpers/.keep b/test/helpers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/integration/.keep b/test/integration/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/mailers/.keep b/test/mailers/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/models/.keep b/test/models/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index 0c22470..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -ENV["RAILS_ENV"] ||= "test" -require_relative "../config/environment" -require "rails/test_help" - -module ActiveSupport - class TestCase - # Run tests in parallel with specified workers - parallelize(workers: :number_of_processors) - - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. - fixtures :all - - # Add more helper methods to be used by all tests here... - end -end From 61d7d8f0575e4c063df489dec6d03b85389396c7 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:13:36 +0500 Subject: [PATCH 06/28] add orders table --- app/models/order.rb | 3 +++ db/migrate/20261006141029_create_orders.rb | 17 +++++++++++++++++ db/schema.rb | 19 ++++++++++++++++++- spec/factories/orders.rb | 12 ++++++++++++ spec/models/order_spec.rb | 5 +++++ 5 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 app/models/order.rb create mode 100644 db/migrate/20261006141029_create_orders.rb create mode 100644 spec/factories/orders.rb create mode 100644 spec/models/order_spec.rb diff --git a/app/models/order.rb b/app/models/order.rb new file mode 100644 index 0000000..d21263c --- /dev/null +++ b/app/models/order.rb @@ -0,0 +1,3 @@ +class Order < ApplicationRecord + belongs_to :customer +end diff --git a/db/migrate/20261006141029_create_orders.rb b/db/migrate/20261006141029_create_orders.rb new file mode 100644 index 0000000..eb25327 --- /dev/null +++ b/db/migrate/20261006141029_create_orders.rb @@ -0,0 +1,17 @@ +class CreateOrders < ActiveRecord::Migration[8.1] + def change + create_table :orders do |t| + t.string :olist_order_id, null: false + t.references :customer, null: false, foreign_key: true + t.string :status, null: false + t.datetime :purchased_at + t.datetime :approved_at + t.datetime :shipped_at + t.datetime :delivered_at + t.datetime :estimated_delivery_at + + t.timestamps + end + add_index :orders, :olist_order_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 2240d9d..b0195e6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_135202) do +ActiveRecord::Schema[8.1].define(version: 2026_10_06_141029) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -25,6 +25,21 @@ t.index ["olist_customer_unique_id"], name: "index_customers_on_olist_customer_unique_id", unique: true end + create_table "orders", force: :cascade do |t| + t.string "olist_order_id", null: false + t.bigint "customer_id", null: false + t.string "status", null: false + t.datetime "purchased_at" + t.datetime "approved_at" + t.datetime "shipped_at" + t.datetime "delivered_at" + t.datetime "estimated_delivery_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["customer_id"], name: "index_orders_on_customer_id" + t.index ["olist_order_id"], name: "index_orders_on_olist_order_id", unique: true + end + create_table "products", force: :cascade do |t| t.string "olist_product_id", null: false t.string "name" @@ -40,4 +55,6 @@ t.datetime "updated_at", null: false t.index ["olist_product_id"], name: "index_products_on_olist_product_id", unique: true end + + add_foreign_key "orders", "customers" end diff --git a/spec/factories/orders.rb b/spec/factories/orders.rb new file mode 100644 index 0000000..d17b54b --- /dev/null +++ b/spec/factories/orders.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :order do + olist_order_id { "MyString" } + customer { nil } + status { "MyString" } + purchased_at { "2026-10-06 19:10:29" } + approved_at { "2026-10-06 19:10:29" } + shipped_at { "2026-10-06 19:10:29" } + delivered_at { "2026-10-06 19:10:29" } + estimated_delivery_at { "2026-10-06 19:10:29" } + end +end diff --git a/spec/models/order_spec.rb b/spec/models/order_spec.rb new file mode 100644 index 0000000..f48229a --- /dev/null +++ b/spec/models/order_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Order, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From e4cf9f45521019e96e7e3b91638f3939decd50a2 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:19:49 +0500 Subject: [PATCH 07/28] add order items table --- app/models/order_item.rb | 4 ++++ db/migrate/20261006141535_create_order_items.rb | 12 ++++++++++++ db/schema.rb | 15 ++++++++++++++- spec/factories/order_items.rb | 8 ++++++++ spec/models/order_item_spec.rb | 5 +++++ 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 app/models/order_item.rb create mode 100644 db/migrate/20261006141535_create_order_items.rb create mode 100644 spec/factories/order_items.rb create mode 100644 spec/models/order_item_spec.rb diff --git a/app/models/order_item.rb b/app/models/order_item.rb new file mode 100644 index 0000000..bc16957 --- /dev/null +++ b/app/models/order_item.rb @@ -0,0 +1,4 @@ +class OrderItem < ApplicationRecord + belongs_to :order + belongs_to :product +end diff --git a/db/migrate/20261006141535_create_order_items.rb b/db/migrate/20261006141535_create_order_items.rb new file mode 100644 index 0000000..744e3ea --- /dev/null +++ b/db/migrate/20261006141535_create_order_items.rb @@ -0,0 +1,12 @@ +class CreateOrderItems < ActiveRecord::Migration[8.1] + def change + create_table :order_items do |t| + t.references :order, null: false, foreign_key: true + t.references :product, null: false, foreign_key: true + t.decimal :price, precision: 10, scale: 2 + t.decimal :freight_value, precision: 10, scale: 2 + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b0195e6..be91f4c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_141029) do +ActiveRecord::Schema[8.1].define(version: 2026_10_06_141535) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -25,6 +25,17 @@ t.index ["olist_customer_unique_id"], name: "index_customers_on_olist_customer_unique_id", unique: true end + create_table "order_items", force: :cascade do |t| + t.bigint "order_id", null: false + t.bigint "product_id", null: false + t.decimal "price", precision: 10, scale: 2 + t.decimal "freight_value", precision: 10, scale: 2 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_id"], name: "index_order_items_on_order_id" + t.index ["product_id"], name: "index_order_items_on_product_id" + end + create_table "orders", force: :cascade do |t| t.string "olist_order_id", null: false t.bigint "customer_id", null: false @@ -56,5 +67,7 @@ t.index ["olist_product_id"], name: "index_products_on_olist_product_id", unique: true end + add_foreign_key "order_items", "orders" + add_foreign_key "order_items", "products" add_foreign_key "orders", "customers" end diff --git a/spec/factories/order_items.rb b/spec/factories/order_items.rb new file mode 100644 index 0000000..5d2660c --- /dev/null +++ b/spec/factories/order_items.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :order_item do + order { nil } + product { nil } + price { "9.99" } + freight_value { "9.99" } + end +end diff --git a/spec/models/order_item_spec.rb b/spec/models/order_item_spec.rb new file mode 100644 index 0000000..3b42412 --- /dev/null +++ b/spec/models/order_item_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe OrderItem, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 75b6519d73827b20324a76d9387427d5d63339b8 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:24:00 +0500 Subject: [PATCH 08/28] add model associations --- app/models/customer.rb | 1 + app/models/order.rb | 2 ++ app/models/product.rb | 2 ++ 3 files changed, 5 insertions(+) diff --git a/app/models/customer.rb b/app/models/customer.rb index 0b52773..65c08e2 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -1,2 +1,3 @@ class Customer < ApplicationRecord + has_many :orders end diff --git a/app/models/order.rb b/app/models/order.rb index d21263c..f7f0566 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -1,3 +1,5 @@ class Order < ApplicationRecord belongs_to :customer + has_many :order_items, dependent: :destroy + has_many :products, through: :order_items end diff --git a/app/models/product.rb b/app/models/product.rb index 35a85ac..1bdd579 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,2 +1,4 @@ class Product < ApplicationRecord + has_many :order_items + has_many :orders, through: :order_items end From bae19a1284feb924557b690d06a1a49b95807c31 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:27:53 +0500 Subject: [PATCH 09/28] add payments table --- app/models/order.rb | 1 + app/models/payment.rb | 3 +++ db/migrate/20261006142602_create_payments.rb | 13 +++++++++++++ spec/factories/payments.rb | 9 +++++++++ spec/models/payment_spec.rb | 5 +++++ 5 files changed, 31 insertions(+) create mode 100644 app/models/payment.rb create mode 100644 db/migrate/20261006142602_create_payments.rb create mode 100644 spec/factories/payments.rb create mode 100644 spec/models/payment_spec.rb diff --git a/app/models/order.rb b/app/models/order.rb index f7f0566..3fc7b41 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -2,4 +2,5 @@ class Order < ApplicationRecord belongs_to :customer has_many :order_items, dependent: :destroy has_many :products, through: :order_items + has_many :payments, dependent: :destroy end diff --git a/app/models/payment.rb b/app/models/payment.rb new file mode 100644 index 0000000..3237adf --- /dev/null +++ b/app/models/payment.rb @@ -0,0 +1,3 @@ +class Payment < ApplicationRecord + belongs_to :order +end diff --git a/db/migrate/20261006142602_create_payments.rb b/db/migrate/20261006142602_create_payments.rb new file mode 100644 index 0000000..de9268d --- /dev/null +++ b/db/migrate/20261006142602_create_payments.rb @@ -0,0 +1,13 @@ +class CreatePayments < ActiveRecord::Migration[8.1] + def change + create_table :payments do |t| + t.references :order, null: false, foreign_key: true + t.integer :sequence + t.string :payment_method + t.integer :installments + t.decimal :amount, null: false, precision: 10, scale: 2 + + t.timestamps + end + end +end diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb new file mode 100644 index 0000000..07e68fd --- /dev/null +++ b/spec/factories/payments.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :payment do + order { nil } + sequence { 1 } + payment_method { "MyString" } + installments { 1 } + amount { "9.99" } + end +end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb new file mode 100644 index 0000000..c185224 --- /dev/null +++ b/spec/models/payment_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Payment, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 413f9f47d7405e36dad583ccf62f3b9f28ea19f5 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:36:28 +0500 Subject: [PATCH 10/28] fix sequence keyword clash in payment factory --- spec/factories/payments.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb index 07e68fd..b448605 100644 --- a/spec/factories/payments.rb +++ b/spec/factories/payments.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :payment do order { nil } - sequence { 1 } + add_attribute(:sequence) { 1 } payment_method { "MyString" } installments { 1 } amount { "9.99" } From 1b09fb23185d7407ca818901247b4c818b77c528 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:36:41 +0500 Subject: [PATCH 11/28] add shipments table --- app/models/order.rb | 1 + app/models/shipment.rb | 3 +++ db/migrate/20261006143358_create_shipments.rb | 14 ++++++++++ db/schema.rb | 27 ++++++++++++++++++- spec/factories/shipments.rb | 10 +++++++ spec/models/shipment_spec.rb | 5 ++++ 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 app/models/shipment.rb create mode 100644 db/migrate/20261006143358_create_shipments.rb create mode 100644 spec/factories/shipments.rb create mode 100644 spec/models/shipment_spec.rb diff --git a/app/models/order.rb b/app/models/order.rb index 3fc7b41..cf946f5 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -3,4 +3,5 @@ class Order < ApplicationRecord has_many :order_items, dependent: :destroy has_many :products, through: :order_items has_many :payments, dependent: :destroy + has_one :shipment, dependent: :destroy end diff --git a/app/models/shipment.rb b/app/models/shipment.rb new file mode 100644 index 0000000..1fd492b --- /dev/null +++ b/app/models/shipment.rb @@ -0,0 +1,3 @@ +class Shipment < ApplicationRecord + belongs_to :order +end diff --git a/db/migrate/20261006143358_create_shipments.rb b/db/migrate/20261006143358_create_shipments.rb new file mode 100644 index 0000000..68eda8f --- /dev/null +++ b/db/migrate/20261006143358_create_shipments.rb @@ -0,0 +1,14 @@ +class CreateShipments < ActiveRecord::Migration[8.1] + def change + create_table :shipments do |t| + t.references :order, null: false, foreign_key: true, index: { unique: true } + t.string :carrier + t.string :tracking_number + t.string :status, null: false + t.string :last_known_location + t.datetime :last_scan_at + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index be91f4c..7df5d2c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_141535) do +ActiveRecord::Schema[8.1].define(version: 2026_10_06_143358) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -51,6 +51,17 @@ t.index ["olist_order_id"], name: "index_orders_on_olist_order_id", unique: true end + create_table "payments", force: :cascade do |t| + t.bigint "order_id", null: false + t.integer "sequence" + t.string "payment_method" + t.integer "installments" + t.decimal "amount", precision: 10, scale: 2, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_id"], name: "index_payments_on_order_id" + end + create_table "products", force: :cascade do |t| t.string "olist_product_id", null: false t.string "name" @@ -67,7 +78,21 @@ t.index ["olist_product_id"], name: "index_products_on_olist_product_id", unique: true end + create_table "shipments", force: :cascade do |t| + t.bigint "order_id", null: false + t.string "carrier" + t.string "tracking_number" + t.string "status", null: false + t.string "last_known_location" + t.datetime "last_scan_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_id"], name: "index_shipments_on_order_id", unique: true + end + add_foreign_key "order_items", "orders" add_foreign_key "order_items", "products" add_foreign_key "orders", "customers" + add_foreign_key "payments", "orders" + add_foreign_key "shipments", "orders" end diff --git a/spec/factories/shipments.rb b/spec/factories/shipments.rb new file mode 100644 index 0000000..0a1f1fd --- /dev/null +++ b/spec/factories/shipments.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :shipment do + order { nil } + carrier { "MyString" } + tracking_number { "MyString" } + status { "MyString" } + last_known_location { "MyString" } + last_scan_at { "2026-10-06 19:33:58" } + end +end diff --git a/spec/models/shipment_spec.rb b/spec/models/shipment_spec.rb new file mode 100644 index 0000000..255339c --- /dev/null +++ b/spec/models/shipment_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Shipment, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From fa3539e0df513c4f1757a7ce3fc6471dbcd71932 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:40:37 +0500 Subject: [PATCH 12/28] add data model diagram --- README.md | 3 +- docs/DATA_MODEL.md | 155 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 docs/DATA_MODEL.md diff --git a/README.md b/README.md index 10291c3..de035c4 100644 --- a/README.md +++ b/README.md @@ -112,4 +112,5 @@ This is a learning project about building a reliable AI agent, not a complete su ## More -The full plan, with architecture, gems by lesson, data mapping and the evaluation plan, is in [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md). +- The full plan, with architecture, gems by lesson, data mapping and the evaluation plan: [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md) +- The database tables and how they relate, as a diagram: [docs/DATA_MODEL.md](docs/DATA_MODEL.md) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..ebf1f46 --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,155 @@ +# Data model + +The support agent's database: who the customers are, what they ordered, how they paid and where +the package is. This is the data the read tools (`get_order`, `get_shipment`, `get_customer`, +`search_orders`) will query. It is built in Lesson 1 and is the same set of tables every later lesson uses. + +## Diagram + +```mermaid +erDiagram + customers ||--o{ orders : "places" + orders ||--|{ order_items : "contains" + products ||--o{ order_items : "appears in" + orders ||--o{ payments : "paid by" + orders ||--o| shipments : "shipped as" + + customers { + bigint id PK + string olist_customer_unique_id UK "NOT NULL" + string name "generated by Faker" + string email "generated by Faker" + string city + string state + } + + orders { + bigint id PK + string olist_order_id UK "NOT NULL" + bigint customer_id FK "NOT NULL" + string status "NOT NULL" + datetime purchased_at + datetime approved_at + datetime shipped_at + datetime delivered_at + datetime estimated_delivery_at + } + + order_items { + bigint id PK + bigint order_id FK "NOT NULL" + bigint product_id FK "NOT NULL" + decimal price "10,2" + decimal freight_value "10,2" + } + + products { + bigint id PK + string olist_product_id UK "NOT NULL" + string name "generated by Faker" + string category "English name" + integer name_length + integer description_length + integer photos_count + integer weight_g + integer length_cm + integer height_cm + integer width_cm + } + + payments { + bigint id PK + bigint order_id FK "NOT NULL" + integer sequence "1st, 2nd payment on the order" + string payment_method + integer installments + decimal amount "10,2 NOT NULL" + } + + shipments { + bigint id PK + bigint order_id FK "UNIQUE, NOT NULL" + string carrier + string tracking_number + string status "NOT NULL" + string last_known_location + datetime last_scan_at + } +``` + +`created_at` and `updated_at` exist on every table and are left out of the diagram to keep it readable. + +
+Text version + +- A **customer** places many **orders** (one customer, zero or more orders). +- An **order** contains one or more **order items**. Each item points at one **product**, so + `order_items` is the join table between orders and products (many to many). +- An order has zero or more **payments**. More than one is normal (a voucher plus a card). +- An order has zero or one **shipment**, enforced by a unique index on `shipments.order_id`. + +
+ +## How to read it + +| Symbol | Meaning | +| --- | --- | +| `\|\|--o{` | exactly one on the left, zero or many on the right | +| `\|\|--\|{` | exactly one on the left, one or many on the right | +| `\|\|--o\|` | exactly one on the left, zero or one on the right | +| PK, FK, UK | primary key, foreign key, unique index | + +## Why it looks like this + +- **Integer primary keys, plus an `olist_*_id` column.** Olist ids are 32-character hex strings. + Integer ids are smaller, faster to join and read naturally (`/orders/1042`). The Olist id is kept only so + the import can match rows across files, so it has a unique index. +- **One `Customer` per real person.** Olist creates a new `customer_id` for every order and keeps a stable + `customer_unique_id` for the person. We store the stable one, so `customer.orders` shows a person's whole + history, which a support agent needs ("this is their third late delivery"). The import translates each + per-order `customer_id` to the right person while loading orders. +- **`order_items` is the middle table.** An order has many products and a product is in many orders, so the + link lives in its own table, with the price paid at purchase time. +- **Money is `decimal(10, 2)`, never float.** Floats store 58.90 as 58.8999..., which is a real bug when the + agent computes a refund. +- **Payments are separate rows.** About 3% of Olist orders have more than one payment. `sequence` is how the + duplicate charge scenario tells a legitimate split payment from being charged twice. +- **`shipments` is its own table, with no copy of the dates.** Olist has no shipment data, so it is generated. + `orders` already holds `shipped_at` and `delivered_at`. The shipment adds what only the carrier knows: + carrier, tracking number, status and the last scan. A package whose last scan was 12 days ago looks lost + even if its status still says `in_transit`. +- **The database enforces the rules, not only Rails.** `null: false`, unique indexes and foreign keys mean a + console typo or a future script cannot create a half-valid row. +- **Deletes.** Items, payments and the shipment die with their order (`dependent: :destroy`). Orders are + never deleted with their customer or product, so order history is protected by the foreign keys. + +## Where each table comes from + +| Table | Source | Notes | +| --- | --- | --- | +| `customers` | Olist customers file | Collapsed to one row per `customer_unique_id`. `name` and `email` generated. | +| `products` | Olist products file plus category translation file | English category. Olist misspells two column names as `lenght`, fixed here. `name` generated. | +| `orders` | Olist orders file | Timestamps renamed (`order_delivered_customer_date` becomes `delivered_at`). | +| `order_items` | Olist order items file | Seller, shipping limit and item number are skipped, the agent does not need them. | +| `payments` | Olist order payments file | `payment_type` becomes `payment_method`, to avoid clashing with Ruby's built-in `method`. | +| `shipments` | Generated by the seed script | Built from each order's delivery dates, plus the planted scenarios. | + +Olist `sellers`, `geolocation` and `reviews` are not used, because a support agent does not need them yet. + +## Associations in Rails + +``` +Customer has_many :orders +Order belongs_to :customer + has_many :order_items, dependent: :destroy + has_many :products, through: :order_items + has_many :payments, dependent: :destroy + has_one :shipment, dependent: :destroy +OrderItem belongs_to :order, :product +Product has_many :order_items + has_many :orders, through: :order_items +Payment belongs_to :order +Shipment belongs_to :order +``` + +The source of truth is always `db/schema.rb`. If the two ever disagree, fix this page. From a9d8e71e1ba2c75e846363edd0bf3eac719245fc Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:46:27 +0500 Subject: [PATCH 13/28] add unique index on customer email --- .../20261006144548_add_unique_index_to_customers_email.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20261006144548_add_unique_index_to_customers_email.rb diff --git a/db/migrate/20261006144548_add_unique_index_to_customers_email.rb b/db/migrate/20261006144548_add_unique_index_to_customers_email.rb new file mode 100644 index 0000000..f8dd0c6 --- /dev/null +++ b/db/migrate/20261006144548_add_unique_index_to_customers_email.rb @@ -0,0 +1,5 @@ +class AddUniqueIndexToCustomersEmail < ActiveRecord::Migration[8.1] + def change + add_index :customers, :email, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7df5d2c..2e8ad50 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_143358) do +ActiveRecord::Schema[8.1].define(version: 2026_10_06_144548) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -22,6 +22,7 @@ t.string "state" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["email"], name: "index_customers_on_email", unique: true t.index ["olist_customer_unique_id"], name: "index_customers_on_olist_customer_unique_id", unique: true end From 86f68eb2dcc4e01aa5d9d0b55662c451d62b03d1 Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 19:51:27 +0500 Subject: [PATCH 14/28] add olist import task skeleton --- lib/tasks/data.rake | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 lib/tasks/data.rake diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake new file mode 100644 index 0000000..0c746f2 --- /dev/null +++ b/lib/tasks/data.rake @@ -0,0 +1,9 @@ +namespace :data do + desc "Import the Olist dataset" + task import_olist: :environment do + path = Rails.root.join("data", "olist", "olist_customers_dataset.csv") + count = 0 + CSV.foreach(path, headers: true) { |row| count += 1 } + puts count + end +end From baddc5ceccc13dc1c9390a0a4eb4a487730ffe7b Mon Sep 17 00:00:00 2001 From: mjesar Date: Tue, 6 Oct 2026 23:46:09 +0500 Subject: [PATCH 15/28] import olist customers --- lib/tasks/data.rake | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake index 0c746f2..d17aee3 100644 --- a/lib/tasks/data.rake +++ b/lib/tasks/data.rake @@ -1,9 +1,32 @@ namespace :data do desc "Import the Olist dataset" task import_olist: :environment do + Faker::Config.random = Random.new(42) + Customer.delete_all + path = Rails.root.join("data", "olist", "olist_customers_dataset.csv") - count = 0 - CSV.foreach(path, headers: true) { |row| count += 1 } - puts count + customers_by_unique_id = {} + customers_by_olist_id = {} + email_number = 0 + + CSV.foreach(path, headers: true) do |row| + customer = customers_by_unique_id[row["customer_unique_id"]] + + unless customer + email_number += 1 + customer = Customer.create!( + olist_customer_unique_id: row["customer_unique_id"], + name: Faker::Name.name, + email: "customer-#{email_number}@example.com", + city: row["customer_city"], + state: row["customer_state"] + ) + customers_by_unique_id[row["customer_unique_id"]] = customer + end + + customers_by_olist_id[row["customer_id"]] = customer + end + + puts "customers: #{Customer.count}, lookup entries: #{customers_by_olist_id.size}" end end From 380757f49abaf185174819b0e3695cf34a067138 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 02:31:15 +0500 Subject: [PATCH 16/28] import olist products and name based emails --- lib/tasks/data.rake | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake index d17aee3..60036be 100644 --- a/lib/tasks/data.rake +++ b/lib/tasks/data.rake @@ -2,22 +2,29 @@ namespace :data do desc "Import the Olist dataset" task import_olist: :environment do Faker::Config.random = Random.new(42) + Product.delete_all Customer.delete_all path = Rails.root.join("data", "olist", "olist_customers_dataset.csv") customers_by_unique_id = {} customers_by_olist_id = {} email_number = 0 + email_domains = %w[example.com example.org example.net] - CSV.foreach(path, headers: true) do |row| + customer_rows = CSV.foreach(path, headers: true) + customer_rows = customer_rows.first(ENV["LIMIT"].to_i) if ENV["LIMIT"] + + customer_rows.each do |row| customer = customers_by_unique_id[row["customer_unique_id"]] unless customer email_number += 1 + name = Faker::Name.name + email_domain = email_domains[email_number % email_domains.size] customer = Customer.create!( olist_customer_unique_id: row["customer_unique_id"], - name: Faker::Name.name, - email: "customer-#{email_number}@example.com", + name: name, + email: "#{name.parameterize(separator: ".")}.#{email_number}@#{email_domain}", city: row["customer_city"], state: row["customer_state"] ) @@ -28,5 +35,34 @@ namespace :data do end puts "customers: #{Customer.count}, lookup entries: #{customers_by_olist_id.size}" + + translations_path = Rails.root.join("data", "olist", "product_category_name_translation.csv") + category_translations = {} + + CSV.foreach(translations_path, headers: true, encoding: "bom|utf-8") do |row| + category_translations[row["product_category_name"]] = row["product_category_name_english"] + end + + products_path = Rails.root.join("data", "olist", "olist_products_dataset.csv") + products_by_olist_id = {} + + CSV.foreach(products_path, headers: true) do |row| + portuguese_category = row["product_category_name"] + + products_by_olist_id[row["product_id"]] = Product.create!( + olist_product_id: row["product_id"], + name: Faker::Commerce.product_name, + category: category_translations.fetch(portuguese_category, portuguese_category), + name_length: row["product_name_lenght"], + description_length: row["product_description_lenght"], + photos_count: row["product_photos_qty"], + weight_g: row["product_weight_g"], + length_cm: row["product_length_cm"], + height_cm: row["product_height_cm"], + width_cm: row["product_width_cm"] + ) + end + + puts "products: #{Product.count}" end end From d1267a5863af5b3907ebe516544de41311459009 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 02:48:09 +0500 Subject: [PATCH 17/28] import olist orders --- lib/tasks/data.rake | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake index 60036be..81686b4 100644 --- a/lib/tasks/data.rake +++ b/lib/tasks/data.rake @@ -2,6 +2,7 @@ namespace :data do desc "Import the Olist dataset" task import_olist: :environment do Faker::Config.random = Random.new(42) + Order.delete_all Product.delete_all Customer.delete_all @@ -64,5 +65,32 @@ namespace :data do end puts "products: #{Product.count}" + + orders_path = Rails.root.join("data", "olist", "olist_orders_dataset.csv") + orders_by_olist_id = {} + skipped_orders = 0 + + CSV.foreach(orders_path, headers: true) do |row| + customer = customers_by_olist_id[row["customer_id"]] + + # With LIMIT only some customers exist, so their orders are the only ones we can keep + unless customer + skipped_orders += 1 + next + end + + orders_by_olist_id[row["order_id"]] = Order.create!( + olist_order_id: row["order_id"], + customer: customer, + status: row["order_status"], + purchased_at: row["order_purchase_timestamp"], + approved_at: row["order_approved_at"], + shipped_at: row["order_delivered_carrier_date"], + delivered_at: row["order_delivered_customer_date"], + estimated_delivery_at: row["order_estimated_delivery_date"] + ) + end + + puts "orders: #{Order.count}, skipped: #{skipped_orders}" end end From d66ee92e1bde00aaea8f1ba15dbf1d95061ef5bb Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 02:58:44 +0500 Subject: [PATCH 18/28] import olist order items and payments --- lib/tasks/data.rake | 59 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake index 81686b4..bb1583b 100644 --- a/lib/tasks/data.rake +++ b/lib/tasks/data.rake @@ -2,10 +2,15 @@ namespace :data do desc "Import the Olist dataset" task import_olist: :environment do Faker::Config.random = Random.new(42) + step = ->(message) { puts "#{Time.current.strftime("%H:%M:%S")} #{message}" } + + OrderItem.delete_all + Payment.delete_all Order.delete_all Product.delete_all Customer.delete_all + step.call("importing customers") path = Rails.root.join("data", "olist", "olist_customers_dataset.csv") customers_by_unique_id = {} customers_by_olist_id = {} @@ -20,6 +25,7 @@ namespace :data do unless customer email_number += 1 + step.call(" #{email_number} customers created") if (email_number % 10_000).zero? name = Faker::Name.name email_domain = email_domains[email_number % email_domains.size] customer = Customer.create!( @@ -35,8 +41,9 @@ namespace :data do customers_by_olist_id[row["customer_id"]] = customer end - puts "customers: #{Customer.count}, lookup entries: #{customers_by_olist_id.size}" + step.call("customers: #{Customer.count}, lookup entries: #{customers_by_olist_id.size}") + step.call("importing products") translations_path = Rails.root.join("data", "olist", "product_category_name_translation.csv") category_translations = {} @@ -64,8 +71,9 @@ namespace :data do ) end - puts "products: #{Product.count}" + step.call("products: #{Product.count}") + step.call("importing orders") orders_path = Rails.root.join("data", "olist", "olist_orders_dataset.csv") orders_by_olist_id = {} skipped_orders = 0 @@ -91,6 +99,51 @@ namespace :data do ) end - puts "orders: #{Order.count}, skipped: #{skipped_orders}" + step.call("orders: #{Order.count}, skipped: #{skipped_orders}") + + step.call("importing order items") + order_items_path = Rails.root.join("data", "olist", "olist_order_items_dataset.csv") + skipped_order_items = 0 + + CSV.foreach(order_items_path, headers: true) do |row| + order = orders_by_olist_id[row["order_id"]] + + unless order + skipped_order_items += 1 + next + end + + OrderItem.create!( + order: order, + product: products_by_olist_id[row["product_id"]], + price: row["price"], + freight_value: row["freight_value"] + ) + end + + step.call("order items: #{OrderItem.count}, skipped: #{skipped_order_items}") + + step.call("importing payments") + payments_path = Rails.root.join("data", "olist", "olist_order_payments_dataset.csv") + skipped_payments = 0 + + CSV.foreach(payments_path, headers: true) do |row| + order = orders_by_olist_id[row["order_id"]] + + unless order + skipped_payments += 1 + next + end + + Payment.create!( + order: order, + sequence: row["payment_sequential"], + payment_method: row["payment_type"], + installments: row["payment_installments"], + amount: row["payment_value"] + ) + end + + step.call("payments: #{Payment.count}, skipped: #{skipped_payments}") end end From b02fc90fd85dddeef6896d34e02c0dffadf0a41a Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 03:02:45 +0500 Subject: [PATCH 19/28] insert olist products in batches --- lib/tasks/data.rake | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake index bb1583b..2df528b 100644 --- a/lib/tasks/data.rake +++ b/lib/tasks/data.rake @@ -52,12 +52,11 @@ namespace :data do end products_path = Rails.root.join("data", "olist", "olist_products_dataset.csv") - products_by_olist_id = {} - CSV.foreach(products_path, headers: true) do |row| + product_rows = CSV.foreach(products_path, headers: true).map do |row| portuguese_category = row["product_category_name"] - products_by_olist_id[row["product_id"]] = Product.create!( + { olist_product_id: row["product_id"], name: Faker::Commerce.product_name, category: category_translations.fetch(portuguese_category, portuguese_category), @@ -68,9 +67,12 @@ namespace :data do length_cm: row["product_length_cm"], height_cm: row["product_height_cm"], width_cm: row["product_width_cm"] - ) + } end + product_rows.each_slice(1_000) { |batch| Product.insert_all(batch) } + product_ids_by_olist_id = Product.pluck(:olist_product_id, :id).to_h + step.call("products: #{Product.count}") step.call("importing orders") @@ -115,7 +117,7 @@ namespace :data do OrderItem.create!( order: order, - product: products_by_olist_id[row["product_id"]], + product_id: product_ids_by_olist_id[row["product_id"]], price: row["price"], freight_value: row["freight_value"] ) From 94e65673eb55344aed770340f7583c391a1cad75 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 03:07:30 +0500 Subject: [PATCH 20/28] document lesson 1 progress and import --- README.md | 11 +++- docs/PROJECT_PLAN.md | 14 +++-- docs/README.md | 18 ++++++ docs/lessons/01-data-model-and-import.md | 79 ++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/lessons/01-data-model-and-import.md diff --git a/README.md b/README.md index de035c4..eff2ae4 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,14 @@ bin/rails db:migrate bin/dev ``` -The dataset import task and the scenario seed script are part of Lesson 1 and do not exist yet. +Put the Olist files in `data/olist/` (see the Data section), then load them: + +```bash +LIMIT=200 bin/rails data:import_olist # small sample: 200 customers and their orders, about half a minute +bin/rails data:import_olist # the full dataset +``` + +Each step prints how many rows it loaded and how many it skipped. The scenario seed script is still to come in Lesson 1. ## Scope @@ -114,3 +121,5 @@ This is a learning project about building a reliable AI agent, not a complete su - The full plan, with architecture, gems by lesson, data mapping and the evaluation plan: [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md) - The database tables and how they relate, as a diagram: [docs/DATA_MODEL.md](docs/DATA_MODEL.md) +- Lesson progress, decisions and what broke: [docs/lessons/01-data-model-and-import.md](docs/lessons/01-data-model-and-import.md) +- Index of all documentation: [docs/README.md](docs/README.md) diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 5244800..a2d829f 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -261,11 +261,13 @@ The project choice is locked. New project plans from other tools are not conside ## 12. Current step: Lesson 1 checklist -- [ ] Create the app: `rails new --database=postgresql --css=tailwind` -- [ ] Add `data/` to `.gitignore` -- [ ] Download the Olist dataset into `data/olist/` and check its license -- [ ] Note the column names of `orders`, `order_items`, `customers` and `products` -- [ ] Write the migrations for `Customer`, `Product`, `Order`, `OrderItem`, `Payment`, `Shipment` -- [ ] Write the import task (`bin/rails data:import_olist`) +- [x] Create the app: `rails new --database=postgresql --css=tailwind` +- [x] Add `data/` to `.gitignore` +- [x] Download the Olist dataset into `data/olist/` and check its license +- [x] Note the column names of `orders`, `order_items`, `customers` and `products` +- [x] Write the migrations for `Customer`, `Product`, `Order`, `OrderItem`, `Payment`, `Shipment` +- [ ] Write the import task (`bin/rails data:import_olist`): written and tested on a sample, batching and a full run still to do - [ ] Write the scenario seed script with a fixed random seed - [ ] Tag `lesson-01` + +Detailed progress, decisions and measured results: [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ba02767 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# Documentation index + +Everything that explains how this project is built and why. Start with the plan, then read the +lesson pages in order. + +| Page | What it is for | +| --- | --- | +| [PROJECT_PLAN.md](PROJECT_PLAN.md) | The whole project: scenario, architecture, stack, tools and permissions, 14 lessons, evaluation plan. | +| [DATA_MODEL.md](DATA_MODEL.md) | The database as a diagram, why each table looks the way it does, and where its data comes from. | +| [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md) | Lesson 1: progress checklist, decisions, what broke and measured results. | + +## Conventions + +- **One page per lesson** in `lessons/`, named `NN-short-topic.md`. Each has the same parts: + status, what was built, decisions and why, what broke, and proof. +- **Facts only.** Numbers in these pages come from a real run. If something has not been run yet, + the page says "expected". +- **The code is the source of truth.** If a page and the code disagree, fix the page. diff --git a/docs/lessons/01-data-model-and-import.md b/docs/lessons/01-data-model-and-import.md new file mode 100644 index 0000000..b354039 --- /dev/null +++ b/docs/lessons/01-data-model-and-import.md @@ -0,0 +1,79 @@ +# Lesson 1: a normal Rails app with real data + +No AI yet. This lesson builds the data the agent will later investigate: customers, orders, +payments and shipments, loaded from a real public dataset. Every later lesson changes this same app. + +Last updated: 2026-10-07 + +## Status + +- [x] App, gems (RSpec, FactoryBot, Faker, csv) +- [x] Six tables, models and associations (see [DATA_MODEL.md](../DATA_MODEL.md)) +- [x] Data model diagram +- [x] Import for customers, products, orders, order items and payments, tested with a 200 customer sample +- [x] Products imported in batches (5 min 22 s down to 17 s) +- [ ] Batch the other four sections (customers, orders, items, payments) +- [ ] Run the full import once, without a limit, and check the totals +- [ ] Scenario seed script: shipments, plus delayed shipment, lost package, return window closed, + final sale item and duplicate charge +- [ ] README: how to run the import +- [ ] Clean `db:reset` run from scratch, then tag `lesson-01` + +## What was built + +`bin/rails data:import_olist` (in `lib/tasks/data.rake`) reads the Olist CSV files from `data/olist/` and +fills the tables in dependency order: customers, products, orders, then order items and payments. +Names, emails and product names are generated, because Olist has none. + +``` +LIMIT=200 bin/rails data:import_olist # a small, consistent sample in about half a minute +bin/rails data:import_olist # everything +``` + +`LIMIT` applies to customers only. Everything below customers follows on its own: an order whose +customer was not loaded is skipped, and so are the items and payments of skipped orders. Each step prints +how many rows it kept and how many it skipped, so the totals can be checked against the files. + +## Decisions and why + +| Decision | Why | +| --- | --- | +| Integer ids plus an `olist_*_id` column | Olist ids are 32 character hex strings. Integers are smaller and read naturally. The Olist id is only for matching rows during import, so it has a unique index. | +| One `Customer` per real person | Olist makes a new `customer_id` for every order. The stable `customer_unique_id` identifies the person, so `customer.orders` shows a real history. The import keeps a lookup from each per-order id to the person. | +| Unknown values stay `nil`, never `0` | A missing weight stored as 0 would make the agent say an item weighs nothing. `nil` lets it say "not recorded". Rails casts the CSV strings, so no `.to_i` is needed. | +| Emails are name plus a counter on reserved domains | `brittany.klocko.7@example.org`. The counter makes every email unique even when names repeat, and `example.com`, `.org` and `.net` can never reach a real mailbox, which matters once the agent can send email. | +| Category translations come from the Olist file, not an AI | A fixed lookup file gives the same result every run, costs nothing and keeps the data reproducible. Categories missing from it keep their Portuguese name. | +| Money is `decimal(10, 2)` | Floats cannot store 58.90 exactly, which is a real bug when computing refunds. | +| The database enforces the rules | `null: false`, unique indexes and foreign keys stop bad rows even if a script or a console typo tries to create them. | +| Fixed random seed | `Faker::Config.random = Random.new(42)` gives everyone the same names, which evals will rely on. | + +## What broke + +- **The first CSV column had a hidden character.** The category translation file starts with a byte + order mark. Reading it with `encoding: "bom|utf-8"` fixes the column name. +- **A column called `sequence` clashed with FactoryBot.** `sequence` is a keyword there, so the generated + factory failed with "wrong number of arguments". The fix is `add_attribute(:sequence) { 1 }`. +- **`delete_all` does not reset ids.** After several test runs the first customer had id 46986. The + ids are harmless, but evals must find records by content or Olist id, never by a fixed integer id. +- **Importing products one at a time took 5 minutes 22 seconds.** Each `create!` is its own database + round trip. Batching with `insert_all` brought it to 17 seconds with identical counts. + +## Proof + +Measured on 2026-10-06 and 2026-10-07. + +| Step | Result | +| --- | --- | +| Customers, full file | 96,096 people from 99,441 rows, 96,096 distinct emails | +| Products, full file | 32,951 | +| Sample run, `LIMIT=200` | 200 customers, 200 orders, 226 order items, 209 payments | +| Sample run honesty check | orders 200 + skipped 99,241 = 99,441 rows; items 226 + 112,424 = 112,650; payments 209 + 103,677 = 103,886 | +| Products import time | 5 min 22 s one by one, 17 s in batches of 1,000 | + +Expected for the full import, not yet run: 99,441 orders, 112,650 order items and 103,886 payments. + +## Where each piece lives + +- Tables and relations: [DATA_MODEL.md](../DATA_MODEL.md) +- Import code: `lib/tasks/data.rake` +- Dataset files: `data/` (git-ignored, see the README for the source and license) From f0bd0738b96e9a9736aa85c0ddcb59222f8ec110 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 18:46:54 +0500 Subject: [PATCH 21/28] add final_sale flag to products --- db/migrate/20261007133706_add_final_sale_to_products.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20261007133706_add_final_sale_to_products.rb diff --git a/db/migrate/20261007133706_add_final_sale_to_products.rb b/db/migrate/20261007133706_add_final_sale_to_products.rb new file mode 100644 index 0000000..5149255 --- /dev/null +++ b/db/migrate/20261007133706_add_final_sale_to_products.rb @@ -0,0 +1,5 @@ +class AddFinalSaleToProducts < ActiveRecord::Migration[8.1] + def change + add_column :products, :final_sale, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 2e8ad50..286d21e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_144548) do +ActiveRecord::Schema[8.1].define(version: 2026_10_07_133706) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -76,6 +76,7 @@ t.integer "width_cm" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "final_sale", default: false, null: false t.index ["olist_product_id"], name: "index_products_on_olist_product_id", unique: true end From 712e4c2a99a9a9649d61858c6aa776ac864f8682 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 18:47:25 +0500 Subject: [PATCH 22/28] generate shipments and plant evaluation scenarios --- lib/tasks/scenarios.rake | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 lib/tasks/scenarios.rake diff --git a/lib/tasks/scenarios.rake b/lib/tasks/scenarios.rake new file mode 100644 index 0000000..7146ff3 --- /dev/null +++ b/lib/tasks/scenarios.rake @@ -0,0 +1,64 @@ +namespace :data do + desc "Generate shipments from the imported orders" + task generate_shipments: :environment do + Faker::Config.random = Random.new(42) + carriers = %w[Correios Jadlog Loggi Total\ Express] + + Shipment.delete_all + + # Orders that were never handed to a carrier (canceled, still processing) have no shipment + Order.where.not(shipped_at: nil).find_in_batches(batch_size: 1_000) do |orders| + rows = orders.map do |order| + { + order_id: order.id, + carrier: carriers.sample(random: Faker::Config.random), + tracking_number: Faker::Alphanumeric.alphanumeric(number: 12).upcase, + status: order.delivered_at ? "delivered" : "in_transit", + last_scan_at: order.delivered_at || order.shipped_at + } + end + + Shipment.insert_all(rows) + end + + puts "shipments: #{Shipment.count}" + end + + desc "Plant the scenarios the evals know the answer to" + task plant_scenarios: :generate_shipments do + # Orders with a shipment are picked by id order, so reruns always plant on the same orders + candidates = Order.joins(:shipment).order(:id) + + delayed = candidates.first + delayed.update!(status: "shipped", shipped_at: 13.days.ago, delivered_at: nil) + delayed.shipment.update!(status: "in_transit", last_scan_at: 11.days.ago, last_known_location: "Curitiba hub") + puts "delayed shipment: order #{delayed.id}" + + lost = candidates.offset(1).first + lost.update!(status: "shipped", shipped_at: 21.days.ago, delivered_at: nil) + lost.shipment.update!(status: "exception", last_scan_at: 16.days.ago, last_known_location: "Sao Paulo sorting center") + puts "lost package: order #{lost.id}" + + expired_return = candidates.offset(2).first + expired_return.update!(status: "delivered", shipped_at: 45.days.ago, delivered_at: 40.days.ago) + expired_return.shipment.update!(status: "delivered", last_scan_at: 40.days.ago) + puts "return window closed: order #{expired_return.id}" + + final_sale = candidates.offset(3).first + final_sale.update!(status: "delivered", shipped_at: 8.days.ago, delivered_at: 5.days.ago) + final_sale.shipment.update!(status: "delivered", last_scan_at: 5.days.ago) + final_sale.products.first.update!(final_sale: true) + puts "final sale item: order #{final_sale.id}" + + duplicate_charge = candidates.offset(4).first + payment = duplicate_charge.payments.order(:sequence).first + # find_or_create_by! keeps reruns from stacking extra duplicates + duplicate_charge.payments.find_or_create_by!( + sequence: payment.sequence + 1, + payment_method: payment.payment_method, + installments: payment.installments, + amount: payment.amount + ) + puts "duplicate charge: order #{duplicate_charge.id}" + end +end From d445485c6bf46b550c7d4d795cfd8279579ff8c9 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 18:47:40 +0500 Subject: [PATCH 23/28] document shipments task and planted scenarios --- README.md | 3 ++- docs/DATA_MODEL.md | 4 ++-- docs/PROJECT_PLAN.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eff2ae4..63df247 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,10 @@ Put the Olist files in `data/olist/` (see the Data section), then load them: ```bash LIMIT=200 bin/rails data:import_olist # small sample: 200 customers and their orders, about half a minute bin/rails data:import_olist # the full dataset +bin/rails data:plant_scenarios # shipments for every order, plus the planted scenarios ``` -Each step prints how many rows it loaded and how many it skipped. The scenario seed script is still to come in Lesson 1. +Each import step prints how many rows it loaded and how many it skipped. `data:plant_scenarios` first generates the shipments, then edits five fixed orders (the first five that have a shipment) so each has a known answer for the evals. It prints the order id of each scenario, and running it again gives the same result. ## Scope diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index ebf1f46..bca5793 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -128,11 +128,11 @@ erDiagram | Table | Source | Notes | | --- | --- | --- | | `customers` | Olist customers file | Collapsed to one row per `customer_unique_id`. `name` and `email` generated. | -| `products` | Olist products file plus category translation file | English category. Olist misspells two column names as `lenght`, fixed here. `name` generated. | +| `products` | Olist products file plus category translation file | English category. Olist misspells two column names as `lenght`, fixed here. `name` generated. `final_sale` is not from Olist: it is false by default and set by a planted scenario. | | `orders` | Olist orders file | Timestamps renamed (`order_delivered_customer_date` becomes `delivered_at`). | | `order_items` | Olist order items file | Seller, shipping limit and item number are skipped, the agent does not need them. | | `payments` | Olist order payments file | `payment_type` becomes `payment_method`, to avoid clashing with Ruby's built-in `method`. | -| `shipments` | Generated by the seed script | Built from each order's delivery dates, plus the planted scenarios. | +| `shipments` | Generated by `data:generate_shipments` | One per order that was handed to a carrier, built from its shipped and delivered dates. `data:plant_scenarios` runs this first, then edits five orders. | Olist `sellers`, `geolocation` and `reviews` are not used, because a support agent does not need them yet. diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index a2d829f..25e67f3 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -267,7 +267,7 @@ The project choice is locked. New project plans from other tools are not conside - [x] Note the column names of `orders`, `order_items`, `customers` and `products` - [x] Write the migrations for `Customer`, `Product`, `Order`, `OrderItem`, `Payment`, `Shipment` - [ ] Write the import task (`bin/rails data:import_olist`): written and tested on a sample, batching and a full run still to do -- [ ] Write the scenario seed script with a fixed random seed +- [x] Write the scenario seed script with a fixed random seed (`bin/rails data:plant_scenarios`) - [ ] Tag `lesson-01` Detailed progress, decisions and measured results: [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md). From 53f91b16c1ca3fd086e21cd43a91756a4e59ef2c Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 20:35:29 +0500 Subject: [PATCH 24/28] import olist in batches and clear shipments first --- db/seeds.rb | 9 ----- lib/tasks/data.rake | 88 +++++++++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 48 deletions(-) delete mode 100644 db/seeds.rb diff --git a/db/seeds.rb b/db/seeds.rb deleted file mode 100644 index 4fbd6ed..0000000 --- a/db/seeds.rb +++ /dev/null @@ -1,9 +0,0 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake index 2df528b..8e68c8a 100644 --- a/lib/tasks/data.rake +++ b/lib/tasks/data.rake @@ -3,7 +3,10 @@ namespace :data do task import_olist: :environment do Faker::Config.random = Random.new(42) step = ->(message) { puts "#{Time.current.strftime("%H:%M:%S")} #{message}" } + # One insert per 1,000 rows is far faster than create! per row. The bang version raises on duplicates instead of silently skipping them + insert_in_batches = ->(model, rows) { rows.each_slice(1_000) { |batch| model.insert_all!(batch) } } + Shipment.delete_all OrderItem.delete_all Payment.delete_all Order.delete_all @@ -12,36 +15,34 @@ namespace :data do step.call("importing customers") path = Rails.root.join("data", "olist", "olist_customers_dataset.csv") - customers_by_unique_id = {} - customers_by_olist_id = {} - email_number = 0 + customer_attributes_by_unique_id = {} + unique_ids_by_olist_id = {} email_domains = %w[example.com example.org example.net] customer_rows = CSV.foreach(path, headers: true) customer_rows = customer_rows.first(ENV["LIMIT"].to_i) if ENV["LIMIT"] customer_rows.each do |row| - customer = customers_by_unique_id[row["customer_unique_id"]] - - unless customer - email_number += 1 - step.call(" #{email_number} customers created") if (email_number % 10_000).zero? - name = Faker::Name.name - email_domain = email_domains[email_number % email_domains.size] - customer = Customer.create!( - olist_customer_unique_id: row["customer_unique_id"], - name: name, - email: "#{name.parameterize(separator: ".")}.#{email_number}@#{email_domain}", - city: row["customer_city"], - state: row["customer_state"] - ) - customers_by_unique_id[row["customer_unique_id"]] = customer - end - - customers_by_olist_id[row["customer_id"]] = customer + unique_id = row["customer_unique_id"] + unique_ids_by_olist_id[row["customer_id"]] = unique_id + next if customer_attributes_by_unique_id.key?(unique_id) + + email_number = customer_attributes_by_unique_id.size + 1 + name = Faker::Name.name + customer_attributes_by_unique_id[unique_id] = { + olist_customer_unique_id: unique_id, + name: name, + email: "#{name.parameterize(separator: ".")}.#{email_number}@#{email_domains[email_number % email_domains.size]}", + city: row["customer_city"], + state: row["customer_state"] + } end - step.call("customers: #{Customer.count}, lookup entries: #{customers_by_olist_id.size}") + insert_in_batches.call(Customer, customer_attributes_by_unique_id.values) + customer_ids_by_unique_id = Customer.pluck(:olist_customer_unique_id, :id).to_h + customer_ids_by_olist_id = unique_ids_by_olist_id.transform_values { |unique_id| customer_ids_by_unique_id[unique_id] } + + step.call("customers: #{Customer.count}, lookup entries: #{customer_ids_by_olist_id.size}") step.call("importing products") translations_path = Rails.root.join("data", "olist", "product_category_name_translation.csv") @@ -70,82 +71,91 @@ namespace :data do } end - product_rows.each_slice(1_000) { |batch| Product.insert_all(batch) } + insert_in_batches.call(Product, product_rows) product_ids_by_olist_id = Product.pluck(:olist_product_id, :id).to_h step.call("products: #{Product.count}") step.call("importing orders") orders_path = Rails.root.join("data", "olist", "olist_orders_dataset.csv") - orders_by_olist_id = {} + order_rows = [] skipped_orders = 0 CSV.foreach(orders_path, headers: true) do |row| - customer = customers_by_olist_id[row["customer_id"]] + customer_id = customer_ids_by_olist_id[row["customer_id"]] # With LIMIT only some customers exist, so their orders are the only ones we can keep - unless customer + unless customer_id skipped_orders += 1 next end - orders_by_olist_id[row["order_id"]] = Order.create!( + order_rows << { olist_order_id: row["order_id"], - customer: customer, + customer_id: customer_id, status: row["order_status"], purchased_at: row["order_purchase_timestamp"], approved_at: row["order_approved_at"], shipped_at: row["order_delivered_carrier_date"], delivered_at: row["order_delivered_customer_date"], estimated_delivery_at: row["order_estimated_delivery_date"] - ) + } end + insert_in_batches.call(Order, order_rows) + order_ids_by_olist_id = Order.pluck(:olist_order_id, :id).to_h + step.call("orders: #{Order.count}, skipped: #{skipped_orders}") step.call("importing order items") order_items_path = Rails.root.join("data", "olist", "olist_order_items_dataset.csv") + order_item_rows = [] skipped_order_items = 0 CSV.foreach(order_items_path, headers: true) do |row| - order = orders_by_olist_id[row["order_id"]] + order_id = order_ids_by_olist_id[row["order_id"]] - unless order + unless order_id skipped_order_items += 1 next end - OrderItem.create!( - order: order, + order_item_rows << { + order_id: order_id, product_id: product_ids_by_olist_id[row["product_id"]], price: row["price"], freight_value: row["freight_value"] - ) + } end + insert_in_batches.call(OrderItem, order_item_rows) + step.call("order items: #{OrderItem.count}, skipped: #{skipped_order_items}") step.call("importing payments") payments_path = Rails.root.join("data", "olist", "olist_order_payments_dataset.csv") + payment_rows = [] skipped_payments = 0 CSV.foreach(payments_path, headers: true) do |row| - order = orders_by_olist_id[row["order_id"]] + order_id = order_ids_by_olist_id[row["order_id"]] - unless order + unless order_id skipped_payments += 1 next end - Payment.create!( - order: order, + payment_rows << { + order_id: order_id, sequence: row["payment_sequential"], payment_method: row["payment_type"], installments: row["payment_installments"], amount: row["payment_value"] - ) + } end + insert_in_batches.call(Payment, payment_rows) + step.call("payments: #{Payment.count}, skipped: #{skipped_payments}") end end From 99ea5f2418a57710a6f0350d3d1608f0586b684e Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 20:35:45 +0500 Subject: [PATCH 25/28] add specs and factories for models and planted scenarios --- spec/factories/customers.rb | 9 +++++ spec/factories/order_items.rb | 8 ++-- spec/factories/orders.rb | 16 ++++---- spec/factories/payments.rb | 6 +-- spec/factories/products.rb | 7 ++++ spec/factories/shipments.rb | 11 +++-- spec/models/customer_spec.rb | 16 ++++++++ spec/models/order_item_spec.rb | 9 ++++- spec/models/order_spec.rb | 35 +++++++++++++++- spec/models/payment_spec.rb | 6 ++- spec/models/product_spec.rb | 15 +++++++ spec/models/shipment_spec.rb | 13 +++++- spec/rails_helper.rb | 2 + spec/tasks/scenarios_spec.rb | 73 ++++++++++++++++++++++++++++++++++ 14 files changed, 197 insertions(+), 29 deletions(-) create mode 100644 spec/factories/customers.rb create mode 100644 spec/factories/products.rb create mode 100644 spec/models/customer_spec.rb create mode 100644 spec/models/product_spec.rb create mode 100644 spec/tasks/scenarios_spec.rb diff --git a/spec/factories/customers.rb b/spec/factories/customers.rb new file mode 100644 index 0000000..47027e8 --- /dev/null +++ b/spec/factories/customers.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :customer do + sequence(:olist_customer_unique_id) { |n| "unique-customer-#{n}" } + sequence(:email) { |n| "customer#{n}@example.com" } + name { "Ana Silva" } + city { "curitiba" } + state { "PR" } + end +end diff --git a/spec/factories/order_items.rb b/spec/factories/order_items.rb index 5d2660c..44ef30a 100644 --- a/spec/factories/order_items.rb +++ b/spec/factories/order_items.rb @@ -1,8 +1,8 @@ FactoryBot.define do factory :order_item do - order { nil } - product { nil } - price { "9.99" } - freight_value { "9.99" } + order + product + price { "59.90" } + freight_value { "12.50" } end end diff --git a/spec/factories/orders.rb b/spec/factories/orders.rb index d17b54b..174ad8b 100644 --- a/spec/factories/orders.rb +++ b/spec/factories/orders.rb @@ -1,12 +1,12 @@ FactoryBot.define do factory :order do - olist_order_id { "MyString" } - customer { nil } - status { "MyString" } - purchased_at { "2026-10-06 19:10:29" } - approved_at { "2026-10-06 19:10:29" } - shipped_at { "2026-10-06 19:10:29" } - delivered_at { "2026-10-06 19:10:29" } - estimated_delivery_at { "2026-10-06 19:10:29" } + customer + sequence(:olist_order_id) { |n| "order-#{n}" } + status { "delivered" } + purchased_at { 30.days.ago } + approved_at { 29.days.ago } + shipped_at { 25.days.ago } + delivered_at { 20.days.ago } + estimated_delivery_at { 18.days.ago } end end diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb index b448605..7044634 100644 --- a/spec/factories/payments.rb +++ b/spec/factories/payments.rb @@ -1,9 +1,9 @@ FactoryBot.define do factory :payment do - order { nil } + order add_attribute(:sequence) { 1 } - payment_method { "MyString" } + payment_method { "credit_card" } installments { 1 } - amount { "9.99" } + amount { "59.90" } end end diff --git a/spec/factories/products.rb b/spec/factories/products.rb new file mode 100644 index 0000000..7a2c2b2 --- /dev/null +++ b/spec/factories/products.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :product do + sequence(:olist_product_id) { |n| "product-#{n}" } + name { "Ergonomic Wooden Chair" } + category { "furniture_decor" } + end +end diff --git a/spec/factories/shipments.rb b/spec/factories/shipments.rb index 0a1f1fd..815b5e0 100644 --- a/spec/factories/shipments.rb +++ b/spec/factories/shipments.rb @@ -1,10 +1,9 @@ FactoryBot.define do factory :shipment do - order { nil } - carrier { "MyString" } - tracking_number { "MyString" } - status { "MyString" } - last_known_location { "MyString" } - last_scan_at { "2026-10-06 19:33:58" } + order + carrier { "Correios" } + sequence(:tracking_number) { |n| "TRACK#{n.to_s.rjust(7, "0")}" } + status { "in_transit" } + last_scan_at { 2.days.ago } end end diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb new file mode 100644 index 0000000..6b55a6a --- /dev/null +++ b/spec/models/customer_spec.rb @@ -0,0 +1,16 @@ +require "rails_helper" + +RSpec.describe Customer, type: :model do + it "has many orders" do + customer = create(:customer) + order = create(:order, customer: customer) + + expect(customer.orders).to eq([ order ]) + end + + it "does not allow two customers with the same email" do + create(:customer, email: "ana@example.com") + + expect { create(:customer, email: "ana@example.com") }.to raise_error(ActiveRecord::RecordNotUnique) + end +end diff --git a/spec/models/order_item_spec.rb b/spec/models/order_item_spec.rb index 3b42412..d72532c 100644 --- a/spec/models/order_item_spec.rb +++ b/spec/models/order_item_spec.rb @@ -1,5 +1,10 @@ -require 'rails_helper' +require "rails_helper" RSpec.describe OrderItem, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it "belongs to an order and a product" do + item = create(:order_item) + + expect(item.order).to be_a(Order) + expect(item.product).to be_a(Product) + end end diff --git a/spec/models/order_spec.rb b/spec/models/order_spec.rb index f48229a..c95eb11 100644 --- a/spec/models/order_spec.rb +++ b/spec/models/order_spec.rb @@ -1,5 +1,36 @@ -require 'rails_helper' +require "rails_helper" RSpec.describe Order, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + let(:order) { create(:order) } + + it "belongs to a customer" do + expect(order.customer).to be_a(Customer) + end + + it "reaches its products through order items" do + item = create(:order_item, order: order) + + expect(order.products).to eq([ item.product ]) + end + + it "has many payments" do + payment = create(:payment, order: order) + + expect(order.payments).to eq([ payment ]) + end + + it "has one shipment" do + shipment = create(:shipment, order: order) + + expect(order.shipment).to eq(shipment) + end + + it "removes its items, payments and shipment when destroyed" do + create(:order_item, order: order) + create(:payment, order: order) + create(:shipment, order: order) + + expect { order.destroy } + .to change { [ OrderItem.count, Payment.count, Shipment.count ] }.from([ 1, 1, 1 ]).to([ 0, 0, 0 ]) + end end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index c185224..ca4657f 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -1,5 +1,7 @@ -require 'rails_helper' +require "rails_helper" RSpec.describe Payment, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it "belongs to an order" do + expect(create(:payment).order).to be_a(Order) + end end diff --git a/spec/models/product_spec.rb b/spec/models/product_spec.rb new file mode 100644 index 0000000..c4ca2d6 --- /dev/null +++ b/spec/models/product_spec.rb @@ -0,0 +1,15 @@ +require "rails_helper" + +RSpec.describe Product, type: :model do + it "is not a final sale item by default" do + expect(create(:product).final_sale).to be(false) + end + + it "reaches its orders through order items" do + product = create(:product) + order = create(:order) + create(:order_item, order: order, product: product) + + expect(product.orders).to eq([ order ]) + end +end diff --git a/spec/models/shipment_spec.rb b/spec/models/shipment_spec.rb index 255339c..80e2204 100644 --- a/spec/models/shipment_spec.rb +++ b/spec/models/shipment_spec.rb @@ -1,5 +1,14 @@ -require 'rails_helper' +require "rails_helper" RSpec.describe Shipment, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it "belongs to an order" do + expect(create(:shipment).order).to be_a(Order) + end + + it "allows only one shipment per order" do + order = create(:order) + create(:shipment, order: order) + + expect { create(:shipment, order: order) }.to raise_error(ActiveRecord::RecordNotUnique) + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ef75d46..bfcbc18 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -35,6 +35,8 @@ abort e.to_s.strip end RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = [ Rails.root.join('spec/fixtures') diff --git a/spec/tasks/scenarios_spec.rb b/spec/tasks/scenarios_spec.rb new file mode 100644 index 0000000..4e3e5c2 --- /dev/null +++ b/spec/tasks/scenarios_spec.rb @@ -0,0 +1,73 @@ +require "rails_helper" +require "rake" + +RSpec.describe "data:plant_scenarios" do + let!(:orders) do + create_list(:order, 5).each do |order| + create(:order_item, order: order) + create(:payment, order: order) + end + end + + before do + Rails.application.load_tasks unless Rake::Task.task_defined?("data:plant_scenarios") + allow($stdout).to receive(:puts) + end + + def plant_scenarios + %w[data:generate_shipments data:plant_scenarios].each { |name| Rake::Task[name].reenable } + Rake::Task["data:plant_scenarios"].invoke + end + + it "creates a shipment for every order that was shipped" do + plant_scenarios + + expect(Shipment.count).to eq(5) + end + + it "plants a delayed shipment: in transit for more than 12 days" do + plant_scenarios + delayed = orders[0].reload + + expect(delayed.shipped_at).to be < 12.days.ago + expect(delayed.delivered_at).to be_nil + expect(delayed.shipment.status).to eq("in_transit") + end + + it "plants a lost package: carrier status exception" do + plant_scenarios + + expect(orders[1].reload.shipment.status).to eq("exception") + end + + it "plants a closed return window: delivered more than 30 days ago" do + plant_scenarios + expired = orders[2].reload + + expect(expired.status).to eq("delivered") + expect(expired.delivered_at).to be < 30.days.ago + end + + it "plants a final sale item inside the return window" do + plant_scenarios + final_sale = orders[3].reload + + expect(final_sale.delivered_at).to be > 30.days.ago + expect(final_sale.products.first.final_sale).to be(true) + end + + it "plants a duplicate charge: two identical payments" do + plant_scenarios + first, second = orders[4].payments.order(:sequence) + + expect(second.amount).to eq(first.amount) + expect(second.payment_method).to eq(first.payment_method) + end + + it "does not stack another duplicate when run twice" do + plant_scenarios + plant_scenarios + + expect(orders[4].payments.count).to eq(2) + end +end From 14d0f05246e71f1613ee7273eb376df07fd93b29 Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 20:37:27 +0500 Subject: [PATCH 26/28] document planted scenarios and import timings --- README.md | 2 +- docs/lessons/01-data-model-and-import.md | 54 ++++++++++++++++++------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 63df247..e4ffc6d 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Put the Olist files in `data/olist/` (see the Data section), then load them: ```bash LIMIT=200 bin/rails data:import_olist # small sample: 200 customers and their orders, about half a minute -bin/rails data:import_olist # the full dataset +bin/rails data:import_olist # the full dataset, about 3 minutes bin/rails data:plant_scenarios # shipments for every order, plus the planted scenarios ``` diff --git a/docs/lessons/01-data-model-and-import.md b/docs/lessons/01-data-model-and-import.md index b354039..b1f55b6 100644 --- a/docs/lessons/01-data-model-and-import.md +++ b/docs/lessons/01-data-model-and-import.md @@ -11,12 +11,12 @@ Last updated: 2026-10-07 - [x] Six tables, models and associations (see [DATA_MODEL.md](../DATA_MODEL.md)) - [x] Data model diagram - [x] Import for customers, products, orders, order items and payments, tested with a 200 customer sample -- [x] Products imported in batches (5 min 22 s down to 17 s) -- [ ] Batch the other four sections (customers, orders, items, payments) -- [ ] Run the full import once, without a limit, and check the totals -- [ ] Scenario seed script: shipments, plus delayed shipment, lost package, return window closed, - final sale item and duplicate charge -- [ ] README: how to run the import +- [x] All five sections imported in batches (about 70 minutes down to about 3) +- [x] Full import run, totals checked against the Olist files +- [x] Shipments generated, plus five planted scenarios: delayed shipment, lost package, return window + closed, final sale item and duplicate charge +- [x] Specs and factories for every model, and for the scenarios task +- [x] README: how to run the import and the scenarios - [ ] Clean `db:reset` run from scratch, then tag `lesson-01` ## What was built @@ -27,13 +27,33 @@ Names, emails and product names are generated, because Olist has none. ``` LIMIT=200 bin/rails data:import_olist # a small, consistent sample in about half a minute -bin/rails data:import_olist # everything +bin/rails data:import_olist # everything, about 3 minutes +bin/rails data:plant_scenarios # shipments for every order, then the five planted scenarios ``` `LIMIT` applies to customers only. Everything below customers follows on its own: an order whose customer was not loaded is skipped, and so are the items and payments of skipped orders. Each step prints how many rows it kept and how many it skipped, so the totals can be checked against the files. +## Planted scenarios + +Real data has no cases where we know the right answer, and an eval needs one. `bin/rails data:plant_scenarios` +(in `lib/tasks/scenarios.rake`) first generates a shipment for every order that was handed to a carrier, then +edits five orders so each tells one story. These are the golden cases the later lessons test the agent against. + +| Scenario | What is planted | Correct answer for the agent | +| --- | --- | --- | +| Delayed shipment | Shipped 13 days ago, shipment `in_transit`, last scan 11 days ago | Apologize, share tracking, wait | +| Lost package | Shipped 21 days ago, shipment `exception`, last scan 16 days ago | Offer a replacement (needs approval) | +| Return window closed | Delivered 40 days ago, the policy allows 30 | Refuse the return | +| Final sale item | Delivered 5 days ago, but the product has `final_sale: true` | Refuse the return | +| Duplicate charge | Two identical payments on one order | Refund one (needs approval) | + +The five orders are the first five (by id) that have a shipment. That rule does not depend on the integer +ids, which change on every import, so reruns always plant on the same orders. The task prints the order id of +each scenario, and running it twice gives the same result (the duplicate payment is not added again). +A product marked final sale is final sale on every order that contains it, which is fine for the evals. + ## Decisions and why | Decision | Why | @@ -45,6 +65,7 @@ how many rows it kept and how many it skipped, so the totals can be checked agai | Category translations come from the Olist file, not an AI | A fixed lookup file gives the same result every run, costs nothing and keeps the data reproducible. Categories missing from it keep their Portuguese name. | | Money is `decimal(10, 2)` | Floats cannot store 58.90 exactly, which is a real bug when computing refunds. | | The database enforces the rules | `null: false`, unique indexes and foreign keys stop bad rows even if a script or a console typo tries to create them. | +| Batch inserts with `insert_all!` | One query per 1,000 rows instead of one per row. The bang version raises on a duplicate instead of silently skipping it, so a bad dataset cannot lose rows unnoticed. It skips validations and callbacks, which is fine while the models have none. | | Fixed random seed | `Faker::Config.random = Random.new(42)` gives everyone the same names, which evals will rely on. | ## What broke @@ -55,8 +76,11 @@ how many rows it kept and how many it skipped, so the totals can be checked agai factory failed with "wrong number of arguments". The fix is `add_attribute(:sequence) { 1 }`. - **`delete_all` does not reset ids.** After several test runs the first customer had id 46986. The ids are harmless, but evals must find records by content or Olist id, never by a fixed integer id. -- **Importing products one at a time took 5 minutes 22 seconds.** Each `create!` is its own database - round trip. Batching with `insert_all` brought it to 17 seconds with identical counts. +- **Importing one row at a time was far too slow.** Each `create!` is its own database round trip. + Products took 5 minutes 22 seconds, and the full import of the other four took about an hour. Batching + with `insert_all!` brought products to 17 seconds and the whole import to about 3 minutes, with identical counts. +- **The import failed once shipments existed.** It cleared `orders` while `shipments` still pointed at them, + and the foreign key refused. Child tables are now cleared first, shipments before orders. ## Proof @@ -64,16 +88,20 @@ Measured on 2026-10-06 and 2026-10-07. | Step | Result | | --- | --- | -| Customers, full file | 96,096 people from 99,441 rows, 96,096 distinct emails | -| Products, full file | 32,951 | +| Full import | 96,096 customers (from 99,441 rows), 32,951 products, 99,441 orders, 112,650 order items, 103,886 payments, nothing skipped | | Sample run, `LIMIT=200` | 200 customers, 200 orders, 226 order items, 209 payments | | Sample run honesty check | orders 200 + skipped 99,241 = 99,441 rows; items 226 + 112,424 = 112,650; payments 209 + 103,677 = 103,886 | -| Products import time | 5 min 22 s one by one, 17 s in batches of 1,000 | +| Import time, one row at a time | about 70 minutes (customers 14 min, orders 16 min, items 25 min, payments 14 min) | +| Import time, batches of 1,000 | about 3 minutes (customers 61 s, orders 70 s, items 21 s, payments 18 s) | +| Shipments generated | 97,658 (orders that never reached a carrier have none) | +| Specs | 20 examples, 0 failures; rubocop clean | -Expected for the full import, not yet run: 99,441 orders, 112,650 order items and 103,886 payments. +Known trade-offs: the import is not wrapped in a transaction (a crash leaves partial data, and rerunning +clears and reloads everything), and it has no automated test of its own, only the totals above. ## Where each piece lives - Tables and relations: [DATA_MODEL.md](../DATA_MODEL.md) - Import code: `lib/tasks/data.rake` +- Shipments and planted scenarios: `lib/tasks/scenarios.rake`, tested in `spec/tasks/scenarios_spec.rb` - Dataset files: `data/` (git-ignored, see the README for the source and license) From f7af08cb69352b6b87bbe37570af0d6450d7a1cb Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 20:45:33 +0500 Subject: [PATCH 27/28] record clean db reset run --- docs/lessons/01-data-model-and-import.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/lessons/01-data-model-and-import.md b/docs/lessons/01-data-model-and-import.md index b1f55b6..8317b86 100644 --- a/docs/lessons/01-data-model-and-import.md +++ b/docs/lessons/01-data-model-and-import.md @@ -17,7 +17,8 @@ Last updated: 2026-10-07 closed, final sale item and duplicate charge - [x] Specs and factories for every model, and for the scenarios task - [x] README: how to run the import and the scenarios -- [ ] Clean `db:reset` run from scratch, then tag `lesson-01` +- [x] Clean `db:reset` run from scratch: import, scenarios and specs all pass +- [ ] Tag `lesson-01` after the merge ## What was built @@ -95,6 +96,7 @@ Measured on 2026-10-06 and 2026-10-07. | Import time, batches of 1,000 | about 3 minutes (customers 61 s, orders 70 s, items 21 s, payments 18 s) | | Shipments generated | 97,658 (orders that never reached a carrier have none) | | Specs | 20 examples, 0 failures; rubocop clean | +| Clean run from scratch | `db:reset`, import (about 3 minutes), `plant_scenarios`, specs: same totals, scenarios on orders 1 to 5 | Known trade-offs: the import is not wrapped in a transaction (a crash leaves partial data, and rerunning clears and reloads everything), and it has no automated test of its own, only the totals above. From b5f2d47c7236a5f7c6c340a636fc2e6bbaea21dc Mon Sep 17 00:00:00 2001 From: mjesar Date: Wed, 7 Oct 2026 20:51:51 +0500 Subject: [PATCH 28/28] run rspec in ci and drop the system test job --- .github/workflows/ci.yml | 53 +++------------------------------------- 1 file changed, 4 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cfe0cd..676fea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,57 +96,12 @@ jobs: with: bundler-cache: true - - name: Run tests + - name: Run specs env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test - - system-test: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - - # redis: - # image: valkey/valkey:8 - # ports: - # - 6379:6379 - # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 - - steps: - - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libvips - - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Run System Tests - env: - RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@localhost:5432 - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test:system - - - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 - if: failure() - with: - name: screenshots - path: ${{ github.workspace }}/tmp/screenshots - if-no-files-found: ignore + run: | + bin/rails db:test:prepare + bundle exec rspec