diff --git a/Gemfile b/Gemfile index b311c94..c5d6977 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,8 @@ gem "omniauth-strava" gem "packwerk", "3.1.0" # Track changes to your models, for auditing or versioning. gem "paper_trail", "15.1.0" +# This library provides a number of PDF::Reader based tools for use in testing PDF output. +gem "pdf-inspector", "1.3.0" # Use pg as the database for Active Record gem "pg", "1.5.4" # Prawn is a pure Ruby PDF generation library [https://github.com/cortiz/prawn-rails] diff --git a/Gemfile.lock b/Gemfile.lock index 93afe9f..092f182 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,6 +14,7 @@ GIT GEM remote: https://rubygems.org/ specs: + Ascii85 (2.0.1) actioncable (7.1.3.4) actionpack (= 7.1.3.4) activesupport (= 7.1.3.4) @@ -106,6 +107,7 @@ GEM kaminari (~> 1.2.2) sassc-rails (~> 2.1) selectize-rails (~> 0.6) + afm (0.2.2) ast (2.4.2) base64 (0.1.1) bcrypt (3.1.20) @@ -204,6 +206,7 @@ GEM ffi (1.17.0-x86_64-linux-gnu) globalid (1.2.1) activesupport (>= 6.1) + hashery (2.1.2) hashie (5.0.0) i18n (1.14.5) concurrent-ruby (~> 1.0) @@ -321,6 +324,14 @@ GEM ast (~> 2.4.1) racc pdf-core (0.10.0) + pdf-inspector (1.3.0) + pdf-reader (>= 1.0, < 3.0.a) + pdf-reader (2.13.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) + afm (~> 0.2.1) + hashery (~> 2.0) + ruby-rc4 + ttfunk pg (1.5.4) prawn (2.5.0) matrix (~> 0.4) @@ -463,6 +474,7 @@ GEM rubocop-rspec_rails (2.28.3) rubocop (~> 1.40) ruby-progressbar (1.13.0) + ruby-rc4 (0.1.5) rubyzip (2.3.2) sassc (2.4.0) ffi (~> 1.9) @@ -576,6 +588,7 @@ DEPENDENCIES omniauth-strava packwerk (= 3.1.0) paper_trail (= 15.1.0) + pdf-inspector (= 1.3.0) pg (= 1.5.4) prawn-rails (= 1.6.0) pry (= 0.14.2) diff --git a/app/controllers/api/v1/pdf_reports_controller.rb b/app/controllers/api/v1/pdf_reports_controller.rb index b7f5470..fbb4804 100644 --- a/app/controllers/api/v1/pdf_reports_controller.rb +++ b/app/controllers/api/v1/pdf_reports_controller.rb @@ -5,15 +5,81 @@ module V1 class PdfReportsController < ApiController def generate authorize :pdf_report, :generate? - pdf = Prawn::Document.new do - text "Mock PDF para Testes" - move_down 10 - text "Data de Geração: #{Time.zone.now.strftime('%d/%m/%Y %H:%M:%S')}" + return entity_name_failure_response if params[:entity_name].blank? + + pdf = if permitted_query_params[:entity_name] == "event_procedures" + event_procedures_pdf + else + medical_shifts_pdf end - disposition = params[:disposition] || "inline" + send_data pdf.render, filename: filename, type: "application/pdf", disposition: disposition + end + + private + + def disposition + permitted_query_params[:disposition] || "inline" + end + + def filename + "#{Time.zone.now.strftime('%d%m%Y')}_report.pdf" + end + + def entity_name_failure_response + render json: { error: "You must inform the `entity_name` parameter" }, status: :bad_request + end + + def event_procedures_pdf + authorized_scope = policy_scope(EventProcedure) + event_procedures = EventProcedures::List.result( + scope: authorized_scope, + params: permitted_query_params + ).event_procedures + total_amount_cents = EventProcedures::TotalAmountCents.call(total_amount_cents_params) + + PdfGeneratorService.new( + relation: event_procedures, + amount: total_amount_cents, + entity_name: permitted_query_params[:entity_name] + ).generate_pdf + end + + def medical_shifts_pdf + authorized_scope = policy_scope(MedicalShift) + medical_shifts = MedicalShifts::List.result( + scope: authorized_scope, + params: permitted_query_params + ).medical_shifts + total_amount_cents = MedicalShifts::TotalAmountCents.call(user_id: current_user.id, month: params[:month]) + + PdfGeneratorService.new( + relation: medical_shifts, + amount: total_amount_cents, + entity_name: permitted_query_params[:entity_name] + ).generate_pdf + end + + def permitted_query_params + params.permit( + :page, + :per_page, + :month, + :year, + :payd, + :entity_name, + :disposition, + hospital: [:name], + health_insurance: [:name] + ).to_h + end - send_data pdf.render, filename: "mock_report.pdf", type: "application/pdf", disposition: disposition + def total_amount_cents_params + { + user_id: current_user.id, + month: permitted_query_params[:month], + year: permitted_query_params[:year] + } end end end diff --git a/app/pdfs/event_procedures_report_pdf.rb b/app/pdfs/event_procedures_report_pdf.rb new file mode 100644 index 0000000..33e2032 --- /dev/null +++ b/app/pdfs/event_procedures_report_pdf.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class EventProceduresReportPdf + attr_reader :pdf, :items, :amount, :title + + def initialize(pdf:, items:, amount:, title:) + @pdf = pdf + @items = items + @amount = amount + @title = title + @header_footer_height = 100 + @line_spacing = 15 + @text_box_padding = 10 + @right_text_offset = 110 + end + + def generate + add_header + add_body + add_footer + end + + private + + def add_header + pdf.repeat(:all) do + HeaderPdf.new(pdf: pdf, title: title).generate + end + end + + def add_footer + pdf.repeat(:all, dynamic: true) do + FooterPdf.new(pdf: pdf, amount: amount).generate + end + end + + def add_body + pdf.move_down @line_spacing + + items.each do |item| + start_new_page_if_needed + pdf.stroke_horizontal_rule + add_item_details(item) + pdf.move_down @line_spacing + end + end + + def start_new_page_if_needed + return unless pdf.cursor < @header_footer_height + + pdf.start_new_page + add_header + end + + def add_item_details(item) + add_item_line(truncate_text(item.patient.name), item_paid?(item)) + add_item_line(truncate_text(item.procedure.name), item.procedure.amount.format) + add_item_line(truncate_text(item.health_insurance.name), item_date(item)) + end + + def add_item_line(left_text, right_text) + pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do + pdf.text_box left_text, at: [3, pdf.cursor - @text_box_padding], size: 10, align: :left + pdf.text_box right_text, at: [pdf.bounds.width - @right_text_offset, pdf.cursor - @text_box_padding], size: 10, + align: :right + end + pdf.move_down @line_spacing + end + + def truncate_text(text, length = 35) + text.length > length ? "#..." : text + end + + def item_paid?(item) + item.payd ? "Pago" : "A Receber" + end + + def item_date(item) + item.date.strftime("%d/%m/%Y") + end +end diff --git a/app/pdfs/footer_pdf.rb b/app/pdfs/footer_pdf.rb new file mode 100644 index 0000000..0ed784c --- /dev/null +++ b/app/pdfs/footer_pdf.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class FooterPdf + attr_reader :pdf, :amount + + def initialize(pdf:, amount:) + @pdf = pdf + @amount = amount + @footer_height = 50 + @footer_spacing = 15 + @footer_font_size = 10 + @right_text_offset = 50 + end + + def generate + pdf.bounding_box([0, 30], width: @pdf.bounds.width, height: @footer_height) do + pdf.stroke_color "000000" + pdf.stroke_horizontal_line(0, pdf.bounds.width, at: 65) + + pdf.text_box "Total", at: [0, pdf.cursor], size: @footer_font_size, align: :left + pdf.text_box amount.total, at: [pdf.bounds.width - @right_text_offset, pdf.cursor], size: @footer_font_size, + align: :right + + pdf.move_down @footer_spacing + + pdf.text_box "A Receber", at: [0, pdf.cursor], size: @footer_font_size, align: :left + pdf.text_box amount.unpaid, at: [pdf.bounds.width - @right_text_offset, pdf.cursor], size: @footer_font_size, + align: :right + + pdf.move_down @footer_spacing + + pdf.text_box "Recebidos", at: [0, pdf.cursor], size: @footer_font_size, align: :left + pdf.text_box amount.payd, at: [pdf.bounds.width - @right_text_offset, pdf.cursor], size: @footer_font_size, + align: :right + end + end +end diff --git a/app/pdfs/header_pdf.rb b/app/pdfs/header_pdf.rb new file mode 100644 index 0000000..ddb4ecc --- /dev/null +++ b/app/pdfs/header_pdf.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class HeaderPdf + attr_reader :pdf, :title + + def initialize(pdf:, title:) + @pdf = pdf + @title = title + @header_spacing = 20 + @header_font_size = 12 + @title_font_size = 20 + end + + def generate + pdf.text "Data: #{Time.zone.now.strftime('%d/%m/%Y')}", align: :right, size: @header_font_size + pdf.move_down @header_spacing + pdf.text title, align: :center, size: @title_font_size, style: :bold + pdf.move_down @header_spacing + end +end diff --git a/app/pdfs/medical_shifts_report_pdf.rb b/app/pdfs/medical_shifts_report_pdf.rb new file mode 100644 index 0000000..615d9a2 --- /dev/null +++ b/app/pdfs/medical_shifts_report_pdf.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class MedicalShiftsReportPdf + attr_reader :pdf, :items, :amount, :title + + def initialize(pdf:, items:, amount:, title:) + @pdf = pdf + @items = items + @amount = amount + @title = title + @header_footer_height = 100 + @line_spacing = 15 + @text_box_padding = 10 + @right_text_offset = 110 + end + + def generate + add_header + add_body + add_footer + end + + private + + def add_header + pdf.repeat(:all) do + HeaderPdf.new(pdf: pdf, title: title).generate + end + end + + def add_footer + pdf.repeat(:all, dynamic: true) do + FooterPdf.new(pdf: pdf, amount: amount).generate + end + end + + def add_body + pdf.move_down @line_spacing + + items.each do |item| + start_new_page_if_needed + pdf.stroke_horizontal_rule + add_item_details(item) + pdf.move_down @line_spacing + end + end + + def start_new_page_if_needed + return unless pdf.cursor < @header_footer_height + + pdf.start_new_page + add_header + end + + def add_item_details(item) + add_item_line(truncate_text(item.hospital_name), item_start_date(item)) + add_item_line(item_workload(item), item.amount.format) + add_item_line(item_start_hour(item), item_paid?(item)) + end + + def add_item_line(left_text, right_text) + pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do + pdf.text_box left_text, at: [3, pdf.cursor - @text_box_padding], size: 10, align: :left + pdf.text_box right_text, at: [pdf.bounds.width - @right_text_offset, pdf.cursor - @text_box_padding], size: 10, + align: :right + end + pdf.move_down @line_spacing + end + + def truncate_text(text, length = 35) + text.length > length ? "#..." : text + end + + def item_shift(item) + item.shift == "Daytime" ? "Diurno" : "Noturno" + end + + def item_workload(item) + "#{item_shift(item)} - #{item.workload_humanize}" + end + + def item_start_date(item) + item.start_date.strftime("%d/%m/%Y") + end + + def item_start_hour(item) + "Início: #{item.start_hour.strftime('%H:%M')}" + end + + def item_paid?(item) + item.payd ? "Pago" : "A Receber" + end +end diff --git a/app/services/pdf_generator_service.rb b/app/services/pdf_generator_service.rb new file mode 100644 index 0000000..2454f96 --- /dev/null +++ b/app/services/pdf_generator_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class PdfGeneratorService + attr_reader :relation, :amount, :entity_name + + def initialize(relation:, amount:, entity_name:) + @relation = relation + @amount = amount + @entity_name = entity_name + end + + def generate_pdf + entity_name == "event_procedures" ? generate_event_procedures_pdf : generate_medical_shifts_pdf + end + + private + + def generate_event_procedures_pdf + Prawn::Document.new do |pdf| + EventProceduresReportPdf.new(pdf: pdf, items: relation, amount: amount, title: "Procedimentos").generate + end + end + + def generate_medical_shifts_pdf + Prawn::Document.new do |pdf| + MedicalShiftsReportPdf.new(pdf: pdf, items: relation, amount: amount, title: "Plantões").generate + end + end +end diff --git a/config/initializers/new_framework_defaults_7_1.rb b/config/initializers/new_framework_defaults_7_1.rb index 685f409..ebedc26 100644 --- a/config/initializers/new_framework_defaults_7_1.rb +++ b/config/initializers/new_framework_defaults_7_1.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + # Be sure to restart your server when you modify this file. # # This file eases your Rails 7.1 framework defaults upgrade. diff --git a/lib/scripts/persist_procedures.rb b/lib/scripts/persist_procedures.rb index 5ec9af1..bab1ade 100644 --- a/lib/scripts/persist_procedures.rb +++ b/lib/scripts/persist_procedures.rb @@ -25,7 +25,8 @@ def populate_database! procedure_instance = procedure_build(procedure[:code], procedure[:name]) save_procedure!(procedure_instance, procedure[:port], procedure[:anesthetic_port]) - end; nil + end + nil rescue StandardError => e raise StandardError, e.message end diff --git a/spec/pdfs/event_procedures_report_pdf_spec.rb b/spec/pdfs/event_procedures_report_pdf_spec.rb new file mode 100644 index 0000000..8055bd6 --- /dev/null +++ b/spec/pdfs/event_procedures_report_pdf_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EventProceduresReportPdf, type: :pdf do + it "generates a report with the correct content" do + user = create(:user) + pdf = Prawn::Document.new + amount = EventProcedures::TotalAmountCents.call(user_id: user.id, month: nil, year: nil) + event_procedures = create_list(:event_procedure, 3, user_id: user.id) + + described_class.new(pdf: pdf, amount: amount, items: event_procedures, title: "Procedimentos").generate + rendered_pdf = pdf.render + text_analysis = PDF::Inspector::Text.analyze(rendered_pdf) + + event_procedures.each do |event_procedure| + expect(text_analysis.strings).to include( + event_procedure.procedure.name, + event_procedure.procedure.amount.format, + event_procedure.patient.name, + event_procedure.health_insurance.name, + event_procedure.date.strftime("%d/%m/%Y") + ) + end + end +end diff --git a/spec/pdfs/footer_pdf_spec.rb b/spec/pdfs/footer_pdf_spec.rb new file mode 100644 index 0000000..3b1f88a --- /dev/null +++ b/spec/pdfs/footer_pdf_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FooterPdf, type: :pdf do + it "generates a footer with the correct content" do + user = create(:user) + create_list(:event_procedure, 3, user_id: user.id) + total_amount_cents = EventProcedures::TotalAmountCents.call( + user_id: user.id, + month: nil, + year: nil + ) + pdf = Prawn::Document.new + + described_class.new(pdf: pdf, amount: total_amount_cents).generate + rendered_pdf = pdf.render + text_analysis = PDF::Inspector::Text.analyze(rendered_pdf) + + expect(text_analysis.strings).to include("Total", total_amount_cents.total) + expect(text_analysis.strings).to include("A Receber", total_amount_cents.unpaid) + expect(text_analysis.strings).to include("Recebidos", total_amount_cents.payd) + end +end diff --git a/spec/pdfs/header_pdf_spec.rb b/spec/pdfs/header_pdf_spec.rb new file mode 100644 index 0000000..c575e92 --- /dev/null +++ b/spec/pdfs/header_pdf_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe HeaderPdf, type: :pdf do + it "generates a header with the correct content" do + pdf = Prawn::Document.new + date = "Data: #{Time.zone.now.strftime('%d/%m/%Y')}" + title = "Procedimentos" + + described_class.new(pdf: pdf, title: title).generate + rendered_pdf = pdf.render + text_analysis = PDF::Inspector::Text.analyze(rendered_pdf) + + expect(text_analysis.strings).to include(date, title) + end +end diff --git a/spec/pdfs/medical_shifts_report_pdf_spec.rb b/spec/pdfs/medical_shifts_report_pdf_spec.rb new file mode 100644 index 0000000..b234c43 --- /dev/null +++ b/spec/pdfs/medical_shifts_report_pdf_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MedicalShiftsReportPdf, type: :pdf do + it "generates a report with the correct content" do + user = create(:user) + pdf = Prawn::Document.new + amount = MedicalShifts::TotalAmountCents.call(user_id: user.id, month: nil) + medical_shifts = create_list(:medical_shift, 3, user_id: user.id) + + described_class.new(pdf: pdf, amount: amount, items: medical_shifts, title: "Plantões").generate + rendered_pdf = pdf.render + text_analysis = PDF::Inspector::Text.analyze(rendered_pdf) + + medical_shifts.each do |medical_shift| + expect(text_analysis.strings).to include( + medical_shift.hospital_name, + medical_shift.amount.format, + medical_shift.start_date.strftime("%d/%m/%Y") + ) + end + end +end diff --git a/spec/requests/api/v1/pdf_reports_request_spec.rb b/spec/requests/api/v1/pdf_reports_request_spec.rb new file mode 100644 index 0000000..2c1268f --- /dev/null +++ b/spec/requests/api/v1/pdf_reports_request_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "PdfReports" do + describe "GET api/v1/pdf_reports/generate" do + context "when entity_name is missing" do + it "returns a bad request error" do + user = create(:user) + headers = auth_token_for(user) + get api_v1_pdf_reports_generate_path, headers: headers + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body["error"]).to eq("You must inform the `entity_name` parameter") + end + end + + context "when entity_name is 'event_procedures'" do + before do + user = create(:user) + headers = auth_token_for(user) + entity_name = "event_procedures" + get api_v1_pdf_reports_generate_path, params: { entity_name: entity_name }, headers: headers + end + + it "returns a PDF file" do + expect(response.content_type).to eq("application/pdf") + expect(response.headers["Content-Disposition"]).to include("inline") + expect(response.body).not_to be_empty + end + + it "includes correct filename" do + expect(response.headers["Content-Disposition"]).to include( + "filename=\"#{Time.zone.now.strftime('%d%m%Y')}_report.pdf\"" + ) + end + end + + context "when entity_name is 'medical_shifts'" do + before do + user = create(:user) + headers = auth_token_for(user) + entity_name = "medical_shifts" + get api_v1_pdf_reports_generate_path, params: { entity_name: entity_name }, headers: headers + end + + it "returns a PDF file" do + expect(response.content_type).to eq("application/pdf") + expect(response.headers["Content-Disposition"]).to include("inline") + expect(response.body).not_to be_empty + end + + it "includes correct filename" do + expect(response.headers["Content-Disposition"]).to include( + "filename=\"#{Time.zone.now.strftime('%d%m%Y')}_report.pdf\"" + ) + end + end + end +end diff --git a/spec/services/pdf_generator_service_spec.rb b/spec/services/pdf_generator_service_spec.rb new file mode 100644 index 0000000..b86c363 --- /dev/null +++ b/spec/services/pdf_generator_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PdfGeneratorService, type: :service do + describe "#generate_pdf" do + context "when entity_name is event_procedures" do + it "generates event_procedures pdf" do + event_procedures = create_list(:event_procedure, 11) + amount = EventProcedures::TotalAmountCents.call(user_id: event_procedures.first.user_id, month: nil, year: nil) + pdf = described_class.new( + relation: event_procedures, amount: amount, entity_name: "event_procedures" + ).generate_pdf + + rendered_pdf = pdf.render + page_analysis = PDF::Inspector::Page.analyze(rendered_pdf) + text_analysis = PDF::Inspector::Text.analyze(rendered_pdf) + + expect(page_analysis.pages.size).to eq(2) + event_procedures.each do |event_procedure| + expect(text_analysis.strings).to include(event_procedure.procedure.name) + end + end + end + + context "when entity_name is medical_shifts" do + it "generates medical_shifts pdf" do + medical_shifts = create_list(:medical_shift, 9) + amount = MedicalShifts::TotalAmountCents.call(user_id: medical_shifts.first.user_id, month: nil) + pdf = described_class.new( + relation: medical_shifts, amount: amount, entity_name: "medical_shifts" + ).generate_pdf + + rendered_pdf = pdf.render + page_analysis = PDF::Inspector::Page.analyze(rendered_pdf) + text_analysis = PDF::Inspector::Text.analyze(rendered_pdf) + + expect(page_analysis.pages.size).to eq(1) + medical_shifts.each do |medical_shift| + expect(text_analysis.strings).to include(medical_shift.hospital_name) + end + end + end + end +end