From aa456b6536f25c8a6e187b9f8d21eae30431e410 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 11:11:19 -0300 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20adiciona=20estrutura=20base=20de?= =?UTF-8?q?=20movimenta=C3=A7=C3=B5es=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/feed.rb | 1 + app/models/feed_movement.rb | 12 ++++++ .../20261024125351_create_feed_movements.rb | 17 ++++++++ db/schema.rb | 17 +++++++- spec/factories/feed_movements.rb | 15 +++++++ spec/models/feed_movement_spec.rb | 40 +++++++++++++++++++ 6 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 app/models/feed_movement.rb create mode 100644 db/migrate/20261024125351_create_feed_movements.rb create mode 100644 spec/factories/feed_movements.rb create mode 100644 spec/models/feed_movement_spec.rb diff --git a/app/models/feed.rb b/app/models/feed.rb index 7ccd502..82250d8 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -1,4 +1,5 @@ class Feed < ApplicationRecord + has_many :feed_movements, dependent: :restrict_with_error validates :name, presence: true validates :stock_quantity, presence: true, numericality: { greater_than_or_equal_to: 0 } diff --git a/app/models/feed_movement.rb b/app/models/feed_movement.rb new file mode 100644 index 0000000..deab914 --- /dev/null +++ b/app/models/feed_movement.rb @@ -0,0 +1,12 @@ +class FeedMovement < ApplicationRecord + belongs_to :feed + + enum :movement_type, { + entry: "entry", + exit: "exit" + }, validate: true + + validates :movement_type, presence: true + validates :quantity, presence: true, numericality: { greater_than: 0 } + validates :occurred_on, presence: true +end diff --git a/db/migrate/20261024125351_create_feed_movements.rb b/db/migrate/20261024125351_create_feed_movements.rb new file mode 100644 index 0000000..1cd05b2 --- /dev/null +++ b/db/migrate/20261024125351_create_feed_movements.rb @@ -0,0 +1,17 @@ +class CreateFeedMovements < ActiveRecord::Migration[8.1] + def change + create_table :feed_movements, id: :uuid do |t| + t.references :feed, null: false, foreign_key: true, type: :uuid + t.string :movement_type, null: false + t.decimal :quantity, precision: 12, scale: 2, null: false + t.decimal :value, precision: 12, scale: 2 + t.string :reason + t.date :occurred_on, null: false + + t.timestamps + end + + add_index :feed_movements, :movement_type + add_index :feed_movements, :occurred_on + end +end diff --git a/db/schema.rb b/db/schema.rb index 5574f05..0c080fb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,25 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_24_090253) do +ActiveRecord::Schema[8.1].define(version: 2026_10_24_125351) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" + create_table "feed_movements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "feed_id", null: false + t.string "movement_type", null: false + t.decimal "quantity", precision: 12, scale: 2, null: false + t.decimal "value", precision: 12, scale: 2 + t.string "reason" + t.date "occurred_on", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["feed_id"], name: "index_feed_movements_on_feed_id" + t.index ["movement_type"], name: "index_feed_movements_on_movement_type" + t.index ["occurred_on"], name: "index_feed_movements_on_occurred_on" + end + create_table "feeds", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.decimal "stock_quantity", precision: 12, scale: 2, default: "0.0", null: false @@ -80,6 +94,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "feed_movements", "feeds" add_foreign_key "occupations", "groups" add_foreign_key "occupations", "terrains" add_foreign_key "sessions", "users" diff --git a/spec/factories/feed_movements.rb b/spec/factories/feed_movements.rb new file mode 100644 index 0000000..45aa8fa --- /dev/null +++ b/spec/factories/feed_movements.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :feed_movement do + association :feed + movement_type { :entry } + quantity { Faker::Number.decimal(l_digits: 2, r_digits: 2) } + value { Faker::Number.decimal(l_digits: 3, r_digits: 2) } + occurred_on { Faker::Date.backward(days: 30) } + + trait :exit do + movement_type { :exit } + value { nil } + reason { Faker::Lorem.sentence } + end + end +end diff --git a/spec/models/feed_movement_spec.rb b/spec/models/feed_movement_spec.rb new file mode 100644 index 0000000..8c4d839 --- /dev/null +++ b/spec/models/feed_movement_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe FeedMovement, type: :model do + describe 'validations' do + it "is valid with valid attributes" do + movement = build(:feed_movement) + expect(movement).to be_valid + end + + it "is invalid without a movement type" do + movement = build(:feed_movement, movement_type: nil) + expect(movement).to_not be_valid + end + + it "is invalid with an invalid movement type" do + movement = build(:feed_movement, movement_type: "invalid_type") + expect(movement).to_not be_valid + end + + it "is invalid without a quantity" do + movement = build(:feed_movement, quantity: nil) + expect(movement).to_not be_valid + end + + it "is invalid with a zero quantity" do + movement = build(:feed_movement, quantity: 0) + expect(movement).to_not be_valid + end + + it "is invalid with a negative quantity" do + movement = build(:feed_movement, quantity: -1) + expect(movement).to_not be_valid + end + + it "is invalid without an occurred on date" do + movement = build(:feed_movement, occurred_on: nil) + expect(movement).to_not be_valid + end + end +end From 94e0ab76a80bfb660f81a7f27daa4ed5e3bdbd00 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 11:23:25 -0300 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20adiciona=20regras=20das=20movimen?= =?UTF-8?q?ta=C3=A7=C3=B5es=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/feed_movement.rb | 2 ++ spec/factories/feed_movements.rb | 6 ++++++ spec/models/feed_movement_spec.rb | 28 ++++++++++++++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/models/feed_movement.rb b/app/models/feed_movement.rb index deab914..4b9b91b 100644 --- a/app/models/feed_movement.rb +++ b/app/models/feed_movement.rb @@ -8,5 +8,7 @@ class FeedMovement < ApplicationRecord validates :movement_type, presence: true validates :quantity, presence: true, numericality: { greater_than: 0 } + validates :value, presence: true, numericality: { greater_than_or_equal_to: 0 }, if: :entry? + validates :reason, presence: true, if: :exit? validates :occurred_on, presence: true end diff --git a/spec/factories/feed_movements.rb b/spec/factories/feed_movements.rb index 45aa8fa..01149bb 100644 --- a/spec/factories/feed_movements.rb +++ b/spec/factories/feed_movements.rb @@ -6,6 +6,12 @@ value { Faker::Number.decimal(l_digits: 3, r_digits: 2) } occurred_on { Faker::Date.backward(days: 30) } + trait :entry do + movement_type { :entry } + value { Faker::Number.decimal(l_digits: 3, r_digits: 2) } + reason { nil } + end + trait :exit do movement_type { :exit } value { nil } diff --git a/spec/models/feed_movement_spec.rb b/spec/models/feed_movement_spec.rb index 8c4d839..113b4c7 100644 --- a/spec/models/feed_movement_spec.rb +++ b/spec/models/feed_movement_spec.rb @@ -2,8 +2,13 @@ RSpec.describe FeedMovement, type: :model do describe 'validations' do - it "is valid with valid attributes" do - movement = build(:feed_movement) + it "is valid with valid entry attributes" do + movement = build(:feed_movement, :entry) + expect(movement).to be_valid + end + + it "is valid with valid exit attributes" do + movement = build(:feed_movement, :exit) expect(movement).to be_valid end @@ -36,5 +41,24 @@ movement = build(:feed_movement, occurred_on: nil) expect(movement).to_not be_valid end + + context "when movement type is entry" do + it "is invalid without a value" do + movement = build(:feed_movement, :entry, value: nil) + expect(movement).to_not be_valid + end + + it "is invalid with a negative value" do + movement = build(:feed_movement, :entry, value: -1) + expect(movement).to_not be_valid + end + end + + context "when movement type is exit" do + it "is invalid without a reason" do + movement = build(:feed_movement, :exit, reason: nil) + expect(movement).to_not be_valid + end + end end end From b6f98ddbafc9366e1772ae6023d185a667621b49 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 11:41:38 -0300 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20adiciona=20entrada=20de=20estoque?= =?UTF-8?q?=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/feed_movements/create_entry.rb | 23 ++++++++ .../feed_movements/create_entry_spec.rb | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 app/services/feed_movements/create_entry.rb create mode 100644 spec/services/feed_movements/create_entry_spec.rb diff --git a/app/services/feed_movements/create_entry.rb b/app/services/feed_movements/create_entry.rb new file mode 100644 index 0000000..5f26418 --- /dev/null +++ b/app/services/feed_movements/create_entry.rb @@ -0,0 +1,23 @@ +module FeedMovements + class CreateEntry + class << self + def call(feed_id:, quantity:, value:, occurred_on:) + ActiveRecord::Base.transaction do + feed = Feed.active.lock.find(feed_id) + + movement = FeedMovement.create!( + feed: feed, + movement_type: :entry, + quantity: quantity, + value: value, + occurred_on: occurred_on + ) + + feed.update!(stock_quantity: feed.stock_quantity + quantity) + + movement + end + end + end + end +end diff --git a/spec/services/feed_movements/create_entry_spec.rb b/spec/services/feed_movements/create_entry_spec.rb new file mode 100644 index 0000000..82522dc --- /dev/null +++ b/spec/services/feed_movements/create_entry_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe FeedMovements::CreateEntry, type: :service do + describe '.call' do + it "creates a entry movement" do + feed = create(:feed, stock_quantity: 100) + movement = described_class.call( + feed_id: feed.id, + quantity: 50, + value: 200, + occurred_on: Date.current + ) + expect(movement).to be_persisted + expect(movement).to be_entry + expect(movement.feed).to eq(feed) + expect(movement.quantity).to eq(50) + expect(movement.value).to eq(200) + expect(movement.occurred_on).to eq(Date.current) + end + + it "increases the feed stock quantity" do + feed = create(:feed, stock_quantity: 100) + described_class.call( + feed_id: feed.id, + quantity: 50, + value: 200, + occurred_on: Date.current + ) + expect(feed.reload.stock_quantity).to eq(150) + end + + it "does not create a movement when attributes are invalid" do + feed = create(:feed, stock_quantity: 100) + expect { + described_class.call( + feed_id: feed.id, + quantity: -50, + value: 200, + occurred_on: Date.current + ) + }.to raise_error(ActiveRecord::RecordInvalid) + expect(feed.reload.stock_quantity).to eq(100) + expect(feed.feed_movements).to be_empty + end + + it "raises an error when the feed is deleted" do + feed = create(:feed, :deleted) + expect { + described_class.call( + feed_id: feed.id, + quantity: 50, + value: 200, + occurred_on: Date.current + ) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end From 034b1d59662d0b5ad416fdf87dc83e54d73328e1 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 12:10:02 -0300 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20adiciona=20endpoint=20de=20entrad?= =?UTF-8?q?a=20de=20estoque=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/v1/feed_movements_controller.rb | 23 +++++++ app/serializers/feed_movement_serializer.rb | 3 + config/routes.rb | 4 +- .../v1/feed_movements/create_entry_spec.rb | 65 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/feed_movements_controller.rb create mode 100644 app/serializers/feed_movement_serializer.rb create mode 100644 spec/requests/api/v1/feed_movements/create_entry_spec.rb diff --git a/app/controllers/api/v1/feed_movements_controller.rb b/app/controllers/api/v1/feed_movements_controller.rb new file mode 100644 index 0000000..3b9c803 --- /dev/null +++ b/app/controllers/api/v1/feed_movements_controller.rb @@ -0,0 +1,23 @@ +module Api + module V1 + class FeedMovementsController < BaseController + before_action :authenticate_request! + + def create_entry + movement = FeedMovements::CreateEntry.call( + feed_id: params[:feed_id], + quantity: entry_params[:quantity], + value: entry_params[:value], + occurred_on: entry_params[:occurred_on] + ) + render json: movement, serializer: FeedMovementSerializer, status: :created + end + + private + + def entry_params + params.require(:entry).permit(:quantity, :value, :occurred_on) + end + end + end +end diff --git a/app/serializers/feed_movement_serializer.rb b/app/serializers/feed_movement_serializer.rb new file mode 100644 index 0000000..ac7d74d --- /dev/null +++ b/app/serializers/feed_movement_serializer.rb @@ -0,0 +1,3 @@ +class FeedMovementSerializer < ActiveModel::Serializer + attributes :id, :feed_id, :movement_type, :quantity, :value, :reason, :occurred_on, :created_at, :updated_at +end diff --git a/config/routes.rb b/config/routes.rb index e3f14e4..9d6f24a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,7 +27,9 @@ resources :occupations, only: [ :create ] - resources :feeds, only: [ :create, :index, :show, :update, :destroy ] + resources :feeds, only: [ :create, :index, :show, :update, :destroy ] do + post "movements/entry", to: "feed_movements#create_entry" + end get "health", to: "health#show" end diff --git a/spec/requests/api/v1/feed_movements/create_entry_spec.rb b/spec/requests/api/v1/feed_movements/create_entry_spec.rb new file mode 100644 index 0000000..a0729b7 --- /dev/null +++ b/spec/requests/api/v1/feed_movements/create_entry_spec.rb @@ -0,0 +1,65 @@ +require 'rails_helper' + +RSpec.describe "POST /api/v1/feeds/:feed_id/movements/entry", type: :request do + let (:user) { create(:user) } + let (:tokens) { Auth::CreateSession.call(user:) } + + let(:headers) do + { + "Authorization" => "Bearer #{tokens[:access_token]}" + } + end + + it "creates a entry movement for the feed" do + feed = create(:feed, stock_quantity: 100) + post "/api/v1/feeds/#{feed.id}/movements/entry", params: { entry: { + quantity: 50, + value: 200, + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:created) + body = response.parsed_body + expect(body).to include( + "feed_id" => feed.id, + "movement_type" => "entry", + "quantity" => "50.0", + "value" => "200.0", + "occurred_on" => Date.current.to_s + ) + expect(feed.reload.stock_quantity).to eq(150) + end + + it "returns unprocessable entity when attributes are invalid" do + feed = create(:feed, stock_quantity: 100) + post "/api/v1/feeds/#{feed.id}/movements/entry", params: { entry: { + quantity: -50, + value: 200, + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(feed.reload.stock_quantity).to eq(100) + end + + it "returns not found when the feed does not exist" do + post "/api/v1/feeds/#{SecureRandom.uuid}/movements/entry", params: { entry: { + quantity: 50, + value: 200, + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:not_found) + end + + it "returns unauthorized when no token is provided" do + feed = create(:feed, stock_quantity: 100) + post "/api/v1/feeds/#{feed.id}/movements/entry", params: { entry: { + quantity: 50, + value: 200, + occurred_on: Date.current + } }, as: :json + + expect(response).to have_http_status(:unauthorized) + end +end From 6a7a794a6dd36876007ab1f28649a47d8ba08490 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 12:33:06 -0300 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20adiciona=20sa=C3=ADda=20de=20esto?= =?UTF-8?q?que=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/feed_movements/create_entry.rb | 8 +- app/services/feed_movements/create_exit.rb | 38 +++++++++ config/locales/en.yml | 2 + config/locales/pt-BR.yml | 2 + .../feed_movements/create_exit_spec.rb | 83 +++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 app/services/feed_movements/create_exit.rb create mode 100644 spec/services/feed_movements/create_exit_spec.rb diff --git a/app/services/feed_movements/create_entry.rb b/app/services/feed_movements/create_entry.rb index 5f26418..aa81d6f 100644 --- a/app/services/feed_movements/create_entry.rb +++ b/app/services/feed_movements/create_entry.rb @@ -6,11 +6,11 @@ def call(feed_id:, quantity:, value:, occurred_on:) feed = Feed.active.lock.find(feed_id) movement = FeedMovement.create!( - feed: feed, + feed:, movement_type: :entry, - quantity: quantity, - value: value, - occurred_on: occurred_on + quantity:, + value:, + occurred_on: ) feed.update!(stock_quantity: feed.stock_quantity + quantity) diff --git a/app/services/feed_movements/create_exit.rb b/app/services/feed_movements/create_exit.rb new file mode 100644 index 0000000..434793d --- /dev/null +++ b/app/services/feed_movements/create_exit.rb @@ -0,0 +1,38 @@ +module FeedMovements + class CreateExit + class << self + def call(feed_id:, quantity:, reason:, occurred_on:) + ActiveRecord::Base.transaction do + feed = Feed.active.lock.find(feed_id) + + movement = FeedMovement.new( + feed:, + movement_type: :exit, + quantity:, + reason:, + occurred_on: + ) + + movement.validate! + + validate_stock!(feed:, quantity: movement.quantity) + + movement.save! + + feed.update!(stock_quantity: feed.stock_quantity - movement.quantity) + + movement + end + end + + private + + def validate_stock!(feed:, quantity:) + return if feed.stock_quantity >= quantity + + feed.errors.add(:stock_quantity, I18n.t("errors.feed_movements.insufficient_stock")) + raise ActiveRecord::RecordInvalid.new(feed) + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 5939c38..1785cd8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -17,6 +17,8 @@ en: inactive_terrain: "The terrain is inactive." same_terrain: "The group is already in this terrain." terrain_occupied: "The terrain is occupied." + feed_movements: + insufficient_stock: "Insufficient stock for the requested quantity." activerecord: models: user: diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index bf65159..6edff15 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -17,6 +17,8 @@ pt-BR: inactive_terrain: "O terreno está inativo." same_terrain: "O grupo já está neste terreno." terrain_occupied: "O terreno está ocupado." + feed_movements: + insufficient_stock: "Estoque insuficiente para a quantidade solicitada." activerecord: errors: messages: diff --git a/spec/services/feed_movements/create_exit_spec.rb b/spec/services/feed_movements/create_exit_spec.rb new file mode 100644 index 0000000..f3791d8 --- /dev/null +++ b/spec/services/feed_movements/create_exit_spec.rb @@ -0,0 +1,83 @@ +require 'rails_helper' + +RSpec.describe FeedMovements::CreateExit, type: :service do + describe '.call' do + it "creates an exit movement" do + feed = create(:feed, stock_quantity: 100) + movement = described_class.call( + feed_id: feed.id, + quantity: 50, + reason: "Sold", + occurred_on: Date.current + ) + expect(movement).to be_persisted + expect(movement).to be_exit + expect(movement.feed).to eq(feed) + expect(movement.quantity).to eq(50) + expect(movement.reason).to eq("Sold") + expect(movement.occurred_on).to eq(Date.current) + end + + it "decreases the feed stock quantity" do + feed = create(:feed, stock_quantity: 100) + described_class.call( + feed_id: feed.id, + quantity: 50, + reason: "Sold", + occurred_on: Date.current + ) + expect(feed.reload.stock_quantity).to eq(50) + end + + it "allows removing the entire available stock" do + feed = create(:feed, stock_quantity: 100) + described_class.call( + feed_id: feed.id, + quantity: 100, + reason: "Sold", + occurred_on: Date.current + ) + expect(feed.reload.stock_quantity).to eq(0) + end + + it "does not allow exit movement when stock is insufficient" do + feed = create(:feed, stock_quantity: 30) + expect { + described_class.call( + feed_id: feed.id, + quantity: 50, + reason: "Sold", + occurred_on: Date.current + ) + }.to raise_error(ActiveRecord::RecordInvalid) + expect(feed.reload.stock_quantity).to eq(30) + expect(feed.feed_movements).to be_empty + end + + it "does not create a movement when attributes are invalid" do + feed = create(:feed, stock_quantity: 100) + expect { + described_class.call( + feed_id: feed.id, + quantity: -50, + reason: "Sold", + occurred_on: Date.current + ) + }.to raise_error(ActiveRecord::RecordInvalid) + expect(feed.reload.stock_quantity).to eq(100) + expect(feed.feed_movements).to be_empty + end + + it "raises an error when the feed is deleted" do + feed = create(:feed, :deleted) + expect { + described_class.call( + feed_id: feed.id, + quantity: 50, + reason: "Sold", + occurred_on: Date.current + ) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end From 5321f8c82bedacfb803a8f7de076ba96a0e6f972 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 12:39:05 -0300 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20adiciona=20endpoint=20de=20sa?= =?UTF-8?q?=C3=ADda=20de=20estoque=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/v1/feed_movements_controller.rb | 14 ++++ config/routes.rb | 1 + .../v1/feed_movements/create_entry_spec.rb | 2 +- .../api/v1/feed_movements/create_exit_spec.rb | 77 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 spec/requests/api/v1/feed_movements/create_exit_spec.rb diff --git a/app/controllers/api/v1/feed_movements_controller.rb b/app/controllers/api/v1/feed_movements_controller.rb index 3b9c803..e8a1bf0 100644 --- a/app/controllers/api/v1/feed_movements_controller.rb +++ b/app/controllers/api/v1/feed_movements_controller.rb @@ -13,11 +13,25 @@ def create_entry render json: movement, serializer: FeedMovementSerializer, status: :created end + def create_exit + movement = FeedMovements::CreateExit.call( + feed_id: params[:feed_id], + quantity: exit_params[:quantity], + reason: exit_params[:reason], + occurred_on: exit_params[:occurred_on] + ) + render json: movement, serializer: FeedMovementSerializer, status: :created + end + private def entry_params params.require(:entry).permit(:quantity, :value, :occurred_on) end + + def exit_params + params.require(:exit).permit(:quantity, :reason, :occurred_on) + end end end end diff --git a/config/routes.rb b/config/routes.rb index 9d6f24a..63a1418 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,6 +29,7 @@ resources :feeds, only: [ :create, :index, :show, :update, :destroy ] do post "movements/entry", to: "feed_movements#create_entry" + post "movements/exit", to: "feed_movements#create_exit" end get "health", to: "health#show" diff --git a/spec/requests/api/v1/feed_movements/create_entry_spec.rb b/spec/requests/api/v1/feed_movements/create_entry_spec.rb index a0729b7..128a5d9 100644 --- a/spec/requests/api/v1/feed_movements/create_entry_spec.rb +++ b/spec/requests/api/v1/feed_movements/create_entry_spec.rb @@ -30,7 +30,7 @@ expect(feed.reload.stock_quantity).to eq(150) end - it "returns unprocessable entity when attributes are invalid" do + it "returns unprocessable content when attributes are invalid" do feed = create(:feed, stock_quantity: 100) post "/api/v1/feeds/#{feed.id}/movements/entry", params: { entry: { quantity: -50, diff --git a/spec/requests/api/v1/feed_movements/create_exit_spec.rb b/spec/requests/api/v1/feed_movements/create_exit_spec.rb new file mode 100644 index 0000000..4c1ef45 --- /dev/null +++ b/spec/requests/api/v1/feed_movements/create_exit_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe "POST /api/v1/feeds/:feed_id/movements/exit", type: :request do + let (:user) { create(:user) } + let (:tokens) { Auth::CreateSession.call(user:) } + + let(:headers) do + { + "Authorization" => "Bearer #{tokens[:access_token]}" + } + end + + it "creates an exit movement for a feed" do + feed = create(:feed, stock_quantity: 100) + post "/api/v1/feeds/#{feed.id}/movements/exit", params: { exit: { + quantity: 50, + reason: "Sold", + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:created) + body = response.parsed_body + expect(body).to include( + "feed_id" => feed.id, + "movement_type" => "exit", + "quantity" => "50.0", + "reason" => "Sold", + "occurred_on" => Date.current.to_s + ) + expect(feed.reload.stock_quantity).to eq(50) + end + + it "returns unprocessable content when attributes are invalid" do + feed = create(:feed, stock_quantity: 100) + post "/api/v1/feeds/#{feed.id}/movements/exit", params: { exit: { + quantity: 150, + reason: "Sold", + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(feed.reload.stock_quantity).to eq(100) + end + + it "returns unprocessable content when stock quantity is insufficient" do + feed = create(:feed, stock_quantity: 30) + post "/api/v1/feeds/#{feed.id}/movements/exit", params: { exit: { + quantity: 50, + reason: "Sold", + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(feed.reload.stock_quantity).to eq(30) + end + + it "returns not found when the feed does not exist" do + post "/api/v1/feeds/#{SecureRandom.uuid}/movements/exit", params: { exit: { + quantity: 50, + reason: "Sold", + occurred_on: Date.current + } }, headers: headers, as: :json + + expect(response).to have_http_status(:not_found) + end + + it "returns unauthorized when no token is provided" do + feed = create(:feed, stock_quantity: 100) + post "/api/v1/feeds/#{feed.id}/movements/exit", params: { exit: { + quantity: 50, + reason: "Sold", + occurred_on: Date.current + } }, as: :json + + expect(response).to have_http_status(:unauthorized) + end +end From e2d2cd51ebb5ecdedb94de171029683e50964f31 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 15:31:19 -0300 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20adiciona=20hist=C3=B3rico=20pagin?= =?UTF-8?q?ado=20de=20movimenta=C3=A7=C3=B5es=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/feed_movements/history.rb | 15 ++++++++++++ spec/services/feed_movements/history_spec.rb | 24 ++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 app/services/feed_movements/history.rb create mode 100644 spec/services/feed_movements/history_spec.rb diff --git a/app/services/feed_movements/history.rb b/app/services/feed_movements/history.rb new file mode 100644 index 0000000..54a2091 --- /dev/null +++ b/app/services/feed_movements/history.rb @@ -0,0 +1,15 @@ +module FeedMovements + class History + class << self + def call(feed_id:, page:, per_page:) + feed = Feeds::Find.call(id: feed_id) + scope = feed.feed_movements.order(occurred_on: :desc, created_at: :desc) + Pagination::Paginator.call( + scope:, + page:, + per_page: + ) + end + end + end +end diff --git a/spec/services/feed_movements/history_spec.rb b/spec/services/feed_movements/history_spec.rb new file mode 100644 index 0000000..a2662a4 --- /dev/null +++ b/spec/services/feed_movements/history_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe FeedMovements::History do + describe '.call' do + it "returns the history of feed movements for a given feed" do + feed = create(:feed) + other_feed = create(:feed) + entry_movement = create(:feed_movement, :entry, feed:, quantity: 50, value: 200, occurred_on: Date.current - 1.day) + exit_movement = create(:feed_movement, :exit, feed:, quantity: 30, reason: "Sold", occurred_on: Date.current) + create(:feed_movement, :entry, feed: other_feed, quantity: 20, value: 100, occurred_on: Date.current) + result = described_class.call(feed_id: feed.id, page: 1, per_page: 10) + expect(result.records).to eq([ exit_movement, entry_movement ]) + end + + it "raises an error when the feed does not exist" do + expect { described_class.call(feed_id: SecureRandom.uuid, page: 1, per_page: 10) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "raises an error when the feed is deleted" do + feed = create(:feed, :deleted) + expect { described_class.call(feed_id: feed.id, page: 1, per_page: 10) }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end From 546f27f282f07da842b24c123fdd9218a35bb144 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 15:37:38 -0300 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20adiciona=20endpoint=20de=20hist?= =?UTF-8?q?=C3=B3rico=20de=20movimenta=C3=A7=C3=B5es=20de=20ra=C3=A7=C3=A3?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/v1/feed_movements_controller.rb | 8 +++ config/routes.rb | 1 + .../api/v1/feed_movements/index_spec.rb | 49 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 spec/requests/api/v1/feed_movements/index_spec.rb diff --git a/app/controllers/api/v1/feed_movements_controller.rb b/app/controllers/api/v1/feed_movements_controller.rb index e8a1bf0..7f0852b 100644 --- a/app/controllers/api/v1/feed_movements_controller.rb +++ b/app/controllers/api/v1/feed_movements_controller.rb @@ -23,6 +23,14 @@ def create_exit render json: movement, serializer: FeedMovementSerializer, status: :created end + def index + result = FeedMovements::History.call( + feed_id: params[:feed_id], + page: params[:page], per_page: params[:per_page] + ) + render json: PaginatedCollectionSerializer.call(result:, serializer: FeedMovementSerializer) + end + private def entry_params diff --git a/config/routes.rb b/config/routes.rb index 63a1418..9838586 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,7 @@ resources :feeds, only: [ :create, :index, :show, :update, :destroy ] do post "movements/entry", to: "feed_movements#create_entry" post "movements/exit", to: "feed_movements#create_exit" + get "movements", to: "feed_movements#index" end get "health", to: "health#show" diff --git a/spec/requests/api/v1/feed_movements/index_spec.rb b/spec/requests/api/v1/feed_movements/index_spec.rb new file mode 100644 index 0000000..7432595 --- /dev/null +++ b/spec/requests/api/v1/feed_movements/index_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe "GET /api/v1/feeds/:feed_id/movements", type: :request do + let (:user) { create(:user) } + let (:tokens) { Auth::CreateSession.call(user:) } + + let(:headers) do + { + "Authorization" => "Bearer #{tokens[:access_token]}" + } + end + + it "returns a list of feed movements for the given feed" do + feed = create(:feed, stock_quantity: 100) + create(:feed_movement, feed:, movement_type: "entry", quantity: 50, value: 200, occurred_on: Date.current) + create(:feed_movement, feed:, movement_type: "exit", quantity: 30, reason: "Sold", occurred_on: Date.current) + get "/api/v1/feeds/#{feed.id}/movements", headers: headers + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body['data'].size).to eq(2) + expect(body['data'].pluck('movement_type')).to match_array([ "entry", "exit" ]) + end + + it "returns a paginated list of feed movements" do + feed = create(:feed, stock_quantity: 100) + create_list(:feed_movement, 3, feed:, movement_type: "entry", quantity: 50, value: 200, occurred_on: Date.current) + get "/api/v1/feeds/#{feed.id}/movements", headers: headers, params: { page: 1, per_page: 2 } + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body['data'].size).to eq(2) + expect(body['meta']).to include( + 'page' => 1, + 'per_page' => 2, + 'total_items' => 3, + 'total_pages' => 2 + ) + end + + it "returns not found when the feed does not exist" do + get "/api/v1/feeds/#{SecureRandom.uuid}/movements", headers: headers + expect(response).to have_http_status(:not_found) + end + + it "returns unauthorized when no token is provided" do + feed = create(:feed, stock_quantity: 100) + get "/api/v1/feeds/#{feed.id}/movements" + expect(response).to have_http_status(:unauthorized) + end +end From 4d177aa476724de46f767fd09b6930953d2abeb4 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 15:39:23 -0300 Subject: [PATCH 09/12] =?UTF-8?q?chore(i18n):=20adiciona=20tradu=C3=A7?= =?UTF-8?q?=C3=B5es=20das=20movimenta=C3=A7=C3=B5es=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/locales/en.yml | 10 ++++++++++ config/locales/pt-BR.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index 1785cd8..06a0ce8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -33,6 +33,9 @@ en: feed: one: "Feed" other: "Feeds" + feed_movement: + one: "Feed Movement" + other: "Feed Movements" attributes: user: name: "Name" @@ -49,3 +52,10 @@ en: feed: name: "Name" stock_quantity: "Stock Quantity" + feed_movement: + feed: "Feed" + movement_type: "Movement Type" + quantity: "Quantity" + value: "Value" + reason: "Reason" + occurred_on: "Occurred On" diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 6edff15..c8c9bf3 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -38,6 +38,9 @@ pt-BR: feed: one: "Ração" other: "Rações" + feed_movement: + one: "Movimentação de Ração" + other: "Movimentações de Ração" attributes: user: name: "Nome" @@ -54,3 +57,10 @@ pt-BR: feed: name: "Nome" stock_quantity: "Quantidade em Estoque" + feed_movement: + feed: "Ração" + movement_type: "Tipo de Movimentação" + quantity: "Quantidade" + value: "Valor" + reason: "Motivo" + occurred_on: "Data da Ocorrência" From 1ae099b3c52f611233165f30d8e5f1c0f82f7e2c Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 16:00:32 -0300 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20ajusta=20retorno=20do=20hist?= =?UTF-8?q?=C3=B3rico=20de=20movimenta=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/api/v1/feed_movements_controller.rb | 7 +++++-- app/serializers/feed_movement_serializer.rb | 13 ++++++++++++- app/serializers/feed_summary_serializer.rb | 3 +++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 app/serializers/feed_summary_serializer.rb diff --git a/app/controllers/api/v1/feed_movements_controller.rb b/app/controllers/api/v1/feed_movements_controller.rb index 7f0852b..feefd87 100644 --- a/app/controllers/api/v1/feed_movements_controller.rb +++ b/app/controllers/api/v1/feed_movements_controller.rb @@ -24,11 +24,14 @@ def create_exit end def index + feed = Feed.active.find(params[:feed_id]) result = FeedMovements::History.call( - feed_id: params[:feed_id], + feed_id: feed.id, page: params[:page], per_page: params[:per_page] ) - render json: PaginatedCollectionSerializer.call(result:, serializer: FeedMovementSerializer) + + paginated = PaginatedCollectionSerializer.call(result:, serializer: FeedMovementSerializer) + render json: { feed: FeedSummarySerializer.new(feed).attributes, **paginated } end private diff --git a/app/serializers/feed_movement_serializer.rb b/app/serializers/feed_movement_serializer.rb index ac7d74d..20f8628 100644 --- a/app/serializers/feed_movement_serializer.rb +++ b/app/serializers/feed_movement_serializer.rb @@ -1,3 +1,14 @@ class FeedMovementSerializer < ActiveModel::Serializer - attributes :id, :feed_id, :movement_type, :quantity, :value, :reason, :occurred_on, :created_at, :updated_at + attributes :id, :movement_type, :quantity, :occurred_on, :created_at, :updated_at + + attribute :value, if: :entry? + attribute :reason, if: :exit? + + def entry? + object.entry? + end + + def exit? + object.exit? + end end diff --git a/app/serializers/feed_summary_serializer.rb b/app/serializers/feed_summary_serializer.rb new file mode 100644 index 0000000..fd7bb56 --- /dev/null +++ b/app/serializers/feed_summary_serializer.rb @@ -0,0 +1,3 @@ +class FeedSummarySerializer < ActiveModel::Serializer + attributes :id, :name +end From d7fae67e2f6680b5d8b54681d7b1a03448bd8e96 Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 16:16:19 -0300 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20controla=20exibi=C3=A7=C3=A3o?= =?UTF-8?q?=20do=20feed=5Fid=20conforme=20contexto=20da=20movimenta=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/v1/feed_movements_controller.rb | 4 ++-- app/serializers/feed_movement_serializer.rb | 16 +++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/v1/feed_movements_controller.rb b/app/controllers/api/v1/feed_movements_controller.rb index feefd87..b3bf65a 100644 --- a/app/controllers/api/v1/feed_movements_controller.rb +++ b/app/controllers/api/v1/feed_movements_controller.rb @@ -10,7 +10,7 @@ def create_entry value: entry_params[:value], occurred_on: entry_params[:occurred_on] ) - render json: movement, serializer: FeedMovementSerializer, status: :created + render json: movement, serializer: FeedMovementSerializer, include_feed_id: true, status: :created end def create_exit @@ -20,7 +20,7 @@ def create_exit reason: exit_params[:reason], occurred_on: exit_params[:occurred_on] ) - render json: movement, serializer: FeedMovementSerializer, status: :created + render json: movement, serializer: FeedMovementSerializer, include_feed_id: true, status: :created end def index diff --git a/app/serializers/feed_movement_serializer.rb b/app/serializers/feed_movement_serializer.rb index 20f8628..054e41e 100644 --- a/app/serializers/feed_movement_serializer.rb +++ b/app/serializers/feed_movement_serializer.rb @@ -1,14 +1,12 @@ class FeedMovementSerializer < ActiveModel::Serializer - attributes :id, :movement_type, :quantity, :occurred_on, :created_at, :updated_at - + attribute :id + attribute :feed_id, if: :include_feed_id? + attributes :movement_type, :quantity attribute :value, if: :entry? attribute :reason, if: :exit? + attributes :occurred_on, :created_at, :updated_at - def entry? - object.entry? - end - - def exit? - object.exit? - end + def include_feed_id? = instance_options[:include_feed_id] + def entry? = object.entry? + def exit? = object.exit? end From 45c4256c330bd8a38b32518ac6d89cf995fdeabe Mon Sep 17 00:00:00 2001 From: Felipe Lima Date: Sat, 24 Oct 2026 16:19:14 -0300 Subject: [PATCH 12/12] =?UTF-8?q?refactor:=20adiciona=20stock=5Fquantity?= =?UTF-8?q?=20ao=20resumo=20do=20hist=C3=B3rico=20de=20ra=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/serializers/feed_summary_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/feed_summary_serializer.rb b/app/serializers/feed_summary_serializer.rb index fd7bb56..11f93b9 100644 --- a/app/serializers/feed_summary_serializer.rb +++ b/app/serializers/feed_summary_serializer.rb @@ -1,3 +1,3 @@ class FeedSummarySerializer < ActiveModel::Serializer - attributes :id, :name + attributes :id, :name, :stock_quantity end