app/workers/invoice_simulation_worker.rb

Summary

Maintainability
A
50 mins
Test Coverage
A
98%
require "invoice_entity_to_json"
# frozen_string_literal: true

class InvoiceSimulationWorker
  include Sidekiq::Worker
  include Sidekiq::Throttled::Worker

  sidekiq_options(
    queue: "dhis2-safe",
    retry: 3
  )

  sidekiq_throttle(
    concurrency: { limit: 3 },
    key_suffix:  ->(_entity, _period, project_id, _with_details, _engine_version, _mocked_data, _simulate_draft) { project_id }
  )

  # Roughly these operations will be done:
  #
  # 1. Find or create our InvoiceSimulationJob (each simulation will have a job for it)
  # 2. Execute
  # 3. Serialize to JSON
  # 4. Store on S3
  # 5. Update the job.
  def perform(entity, period, project_id, with_details, engine_version, mocked_data, simulate_draft)
    project = Project.find(project_id)
    InvoicingSimulationJob.execute(project.project_anchor, period, entity) do |job|
      serialized_json = InvoiceSimulationWorker::Simulation.new(entity, period, project_id, engine_version, with_details, mocked_data, simulate_draft).call

      if job
        name = format("%s.json", [project_id.to_s, entity, period].map(&:underscore).join("-"))
        active_storage_blob = uploaded_blob(name, serialized_json)
        job.result.attach(active_storage_blob)
      end
    end
  end

  # Why don't we just use `result.attach(io: <some_io>)`?
  #
  # We want the json to be gzipped, so S3 can return gzipped JSON and
  # our payloads are decidedly smaller, unfortunately ActiveStorage
  # can't help use with that (for now).
  #
  # That's why this method exists, it handles the gzipping and
  # uploading, then hands back the key of the upload to an
  # `ActiveStorage::Blob` so that we do have a normal
  # ActiveStorage::Blob and can use `ActiveStorage` as you would
  # expect. The only difference is that we did the upload ourselves.
  def uploaded_blob(name, serialized_json)
    gzipped = gzip(serialized_json)
    s3_client = ActiveStorage::Blob.service.client
    bucket = ActiveStorage::Blob.service.bucket
    blob = ActiveStorage::Blob.new
    io = StringIO.new(gzipped)
    blob.filename = name
    blob.checksum = blob.send(:compute_checksum_in_chunks, io)
    blob.byte_size = io.size
    blob.content_type = "application/json"

    bucket.object(blob.key).put(body: io, content_type: "application/json", content_encoding: "gzip")
    blob.save
    blob
  end

  def gzip(string)
    wio = StringIO.new("w")
    w_gz = Zlib::GzipWriter.new(wio)
    w_gz.write(string)
    w_gz.close
    compressed = wio.string
  end

  class Simulation
    class ErrorDuringSimulation < StandardError; end

    attr_accessor :entity, :period, :project_id, :with_details, :engine_version, :simulate_draft

    # The class that does all the simulating.
    #
    # It has a lot of input-arguments, but it's basically a serialized
    # version of an `InvoicingReqest`, from there it will build up the
    # original `InvoicingRequest` and then render it to JSON.
    def initialize(*args)
      @entity, @period, @project_id, @with_details, @engine_version, @mocked_data, @simulate_draft = *args
    end

    def call
      # Note: Both `invoice_entity` and `invoice_request` build up
      # state here, that's why they are memoized.
      invoicing_entity.call
      indexed_project = Invoicing::IndexedProject.new(project, invoicing_entity.orbf_project)
      Invoicing::MapToInvoices.new(invoicing_request, invoicing_entity.fetch_and_solve.solver).call
      json = InvoiceEntityToJson.new(invoicing_entity).call
      json
    rescue StandardError => e
      exception = ErrorDuringSimulation.new(e.message)
      exception.set_backtrace(e.backtrace)
      raise exception
    end

    def invoicing_entity
      @invoicing_entity ||= Invoicing::InvoiceEntity.new(project.project_anchor, invoicing_request, invoicing_options)
    end

    def invoicing_request
      return @request if @request

      request_options = {
        project:        project,
        year:           year,
        quarter:        quarter,
        entity:         @entity,
        with_details:   @with_details,
        engine_version: project.engine_version
      }
      request_options[:mock_values] = "1" if @mocked_data

      @request ||= InvoicingRequest.new(request_options)
    end

    def invoicing_options
      Invoicing::InvoicingOptions.new(
        publish_to_dhis2:       false,
        force_project_id:       simulate_draft? ? project.id : nil,
        allow_fresh_dhis2_data: simulate_draft?
      )
    end

    def simulate_draft?
      !!@simulate_draft
    end

    def year
      @period.split("Q").first
    end

    def quarter
      @period.split("Q").last
    end

    def project
      @project ||= Project.find(project_id)
    end
  end
end