mirror of
https://github.com/System-End/theseus.git
synced 2026-04-19 19:55:10 +00:00
360 lines
12 KiB
Ruby
360 lines
12 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: batches
|
|
#
|
|
# id :bigint not null, primary key
|
|
# aasm_state :string
|
|
# address_count :integer
|
|
# field_mapping :jsonb
|
|
# letter_height :decimal(, )
|
|
# letter_mailing_date :date
|
|
# letter_processing_category :integer
|
|
# letter_return_address_name :string
|
|
# letter_weight :decimal(, )
|
|
# letter_width :decimal(, )
|
|
# tags :citext default([]), is an Array
|
|
# template_cycle :string default([]), is an Array
|
|
# type :string not null
|
|
# warehouse_user_facing_title :string
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# letter_mailer_id_id :bigint
|
|
# letter_queue_id :bigint
|
|
# letter_return_address_id :bigint
|
|
# user_id :bigint not null
|
|
# warehouse_template_id :bigint
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_batches_on_letter_mailer_id_id (letter_mailer_id_id)
|
|
# index_batches_on_letter_queue_id (letter_queue_id)
|
|
# index_batches_on_letter_return_address_id (letter_return_address_id)
|
|
# index_batches_on_tags (tags) USING gin
|
|
# index_batches_on_type (type)
|
|
# index_batches_on_user_id (user_id)
|
|
# index_batches_on_warehouse_template_id (warehouse_template_id)
|
|
#
|
|
# Foreign Keys
|
|
#
|
|
# fk_rails_... (letter_mailer_id_id => usps_mailer_ids.id)
|
|
# fk_rails_... (letter_queue_id => letter_queues.id)
|
|
# fk_rails_... (letter_return_address_id => return_addresses.id)
|
|
# fk_rails_... (user_id => users.id)
|
|
# fk_rails_... (warehouse_template_id => warehouse_templates.id)
|
|
#
|
|
class Letter::Batch < Batch
|
|
def self.policy_class = Letter::BatchPolicy
|
|
|
|
self.inheritance_column = "type"
|
|
# default_scope { where(type: 'letters') }
|
|
has_many :letters, dependent: :destroy
|
|
belongs_to :mailer_id, class_name: "USPS::MailerId", foreign_key: "letter_mailer_id_id", optional: true
|
|
belongs_to :letter_return_address, class_name: "ReturnAddress", optional: true
|
|
belongs_to :letter_queue, :class_name => "Letter::Queue", optional: true
|
|
|
|
# Add ActiveStorage attachment for the batch label PDF
|
|
has_one_attached :pdf_label
|
|
|
|
# Add batch-level letter specifications
|
|
attribute :letter_height, :decimal
|
|
attribute :letter_width, :decimal
|
|
attribute :letter_weight, :decimal
|
|
attribute :letter_processing_category, :integer
|
|
attribute :user_facing_title, :string
|
|
attribute :letter_return_address_name, :string
|
|
attribute :letter_queue_id, :integer
|
|
attr_accessor :template, :template_cycle
|
|
attribute :letter_mailing_date, :date
|
|
|
|
validates :letter_height, :letter_width, :letter_weight, presence: true, numericality: { greater_than: 0 }
|
|
validates :mailer_id, presence: true
|
|
validates :letter_return_address, presence: true, on: :process
|
|
validates :letter_mailing_date, presence: true, on: :process
|
|
validate :mailing_date_not_in_past, if: -> { letter_mailing_date.present? }, on: :create
|
|
validates :letter_processing_category, presence: true
|
|
|
|
after_update :update_letter_tags, if: :saved_change_to_tags?
|
|
|
|
def self.model_name = Batch.model_name
|
|
|
|
# Directly attach a PDF to this batch
|
|
def attach_pdf(pdf_data)
|
|
io = StringIO.new(pdf_data)
|
|
|
|
pdf_label.attach(
|
|
io: io,
|
|
filename: "label_batch_#{Time.now.to_i}.pdf",
|
|
content_type: "application/pdf",
|
|
)
|
|
end
|
|
|
|
def process!(options = {})
|
|
return false unless fields_mapped?
|
|
|
|
# Set postage types and user_facing_title for all letters based on options
|
|
if options[:us_postage_type].present? || options[:intl_postage_type].present? || options[:user_facing_title].present?
|
|
letters.each do |letter|
|
|
letter.mailing_date = letter_mailing_date
|
|
if letter.return_address.us?
|
|
# For US return addresses, use the US postage type
|
|
letter.postage_type = options[:us_postage_type]
|
|
else
|
|
# For non-US return addresses, must use international origin
|
|
letter.postage_type = "international_origin"
|
|
end
|
|
letter.user_facing_title = options[:user_facing_title] if options[:user_facing_title].present?
|
|
letter.save!
|
|
end
|
|
end
|
|
|
|
# Purchase indicia for all letters if needed
|
|
if options[:payment_account].present? &&
|
|
(options[:us_postage_type] == "indicia" || options[:intl_postage_type] == "indicia")
|
|
# Check if there are sufficient funds before processing
|
|
indicia_cost = letters.includes(:address).sum do |letter|
|
|
if letter.postage_type == "indicia"
|
|
if letter.address.us?
|
|
USPS::PricingEngine.metered_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.non_machinable
|
|
)
|
|
else
|
|
flirted = letter.flirt
|
|
USPS::PricingEngine.metered_price(
|
|
flirted[:processing_category],
|
|
flirted[:weight],
|
|
flirted[:non_machinable]
|
|
)
|
|
end
|
|
else
|
|
0
|
|
end
|
|
end
|
|
|
|
unless options[:payment_account].check_funds_available(indicia_cost)
|
|
raise "...we're out of money (ask Nora to put at least #{ActiveSupport::NumberHelper.number_to_currency(indicia_cost)} in the #{options[:payment_account].display_name} account!)"
|
|
end
|
|
|
|
purchase_batch_indicia(options[:payment_account])
|
|
end
|
|
|
|
# Generate PDF labels with the provided options
|
|
generate_labels(options)
|
|
|
|
mark_processed!
|
|
end
|
|
|
|
def regenerate_labels!(options = {})
|
|
labels_pdf.purge
|
|
generate_labels(options)
|
|
end
|
|
|
|
# Purchase indicia for all letters in the batch using a single payment token
|
|
def purchase_batch_indicia(payment_account)
|
|
# Create a single payment token for the entire batch
|
|
payment_token = payment_account.create_payment_token
|
|
|
|
# Preload associations to avoid N+1 queries
|
|
letters.includes(:address).each do |letter|
|
|
next unless letter.postage_type == "indicia" && letter.usps_indicium.nil?
|
|
|
|
# Create and purchase indicia for each letter using the same payment token
|
|
indicium = USPS::Indicium.new(
|
|
letter: letter,
|
|
payment_account: payment_account,
|
|
mailing_date: letter_mailing_date,
|
|
)
|
|
indicium.buy!(payment_token)
|
|
end
|
|
end
|
|
|
|
def postage_cost
|
|
# Preload associations to avoid N+1 queries
|
|
letters.includes(:address, :usps_indicium).sum do |letter|
|
|
if letter.postage_type == "indicia"
|
|
if letter.usps_indicium.present?
|
|
# Use actual indicia price if indicia are bought
|
|
letter.usps_indicium.postage + letter.usps_indicium.fees
|
|
elsif letter.address.us?
|
|
# For US mail without bought indicia, use metered price
|
|
USPS::PricingEngine.metered_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.non_machinable
|
|
)
|
|
else
|
|
# For international mail without bought indicia, use FLIRT-ed price
|
|
flirted = letter.flirt
|
|
USPS::PricingEngine.metered_price(
|
|
flirted[:processing_category],
|
|
flirted[:weight],
|
|
flirted[:non_machinable]
|
|
)
|
|
end
|
|
else
|
|
# For stamps, use stamp price for US and desired price for international
|
|
if letter.address.us?
|
|
USPS::PricingEngine.domestic_stamp_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.non_machinable
|
|
)
|
|
else
|
|
USPS::PricingEngine.fcmi_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.address.country
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
alias_method :total_cost, :postage_cost
|
|
|
|
def postage_cost_difference(us_postage_type: nil, intl_postage_type: nil)
|
|
# Preload associations to avoid N+1 queries
|
|
letters.includes(:address, :usps_indicium).each_with_object({ us: 0, intl: 0 }) do |letter, differences|
|
|
# Determine what postage type this letter would use
|
|
effective_postage_type = if letter.address.us?
|
|
us_postage_type || letter.postage_type
|
|
else
|
|
intl_postage_type || letter.postage_type
|
|
end
|
|
|
|
# Skip if not switching to indicia
|
|
next unless effective_postage_type == "indicia"
|
|
|
|
if letter.address.us?
|
|
# For US mail:
|
|
# Retail price is stamp_price
|
|
retail_price = USPS::PricingEngine.domestic_stamp_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.non_machinable
|
|
)
|
|
|
|
# Indicia price is metered_price
|
|
indicia_price = if letter.usps_indicium.present?
|
|
letter.usps_indicium.postage
|
|
else
|
|
USPS::PricingEngine.metered_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.non_machinable
|
|
)
|
|
end
|
|
|
|
# Difference should be negative (savings)
|
|
differences[:us] += indicia_price - retail_price
|
|
else
|
|
# For international mail:
|
|
# Retail price is desired_price
|
|
retail_price = USPS::PricingEngine.fcmi_price(
|
|
letter.processing_category,
|
|
letter.weight,
|
|
letter.address.country
|
|
)
|
|
|
|
# Indicia price is flirted price (higher than retail)
|
|
indicia_price = if letter.usps_indicium.present?
|
|
letter.usps_indicium.postage
|
|
else
|
|
# Use flirt to get the closest US price that's higher than the FCMI rate
|
|
flirted = letter.flirt
|
|
USPS::PricingEngine.metered_price(
|
|
flirted[:processing_category],
|
|
flirted[:weight],
|
|
flirted[:non_machinable]
|
|
)
|
|
end
|
|
|
|
# Difference should be positive (additional cost)
|
|
differences[:intl] += indicia_price - retail_price
|
|
end
|
|
end
|
|
end
|
|
|
|
def mailing_date_not_in_past
|
|
if letter_mailing_date < Date.current
|
|
errors.add(:letter_mailing_date, "cannot be in the past")
|
|
end
|
|
end
|
|
|
|
def default_mailing_date
|
|
now = Time.current.in_time_zone("Eastern Time (US & Canada)")
|
|
today = now.to_date
|
|
|
|
# If it's before 4PM EST on a business day, default to today
|
|
if now.hour < 16 && today.on_weekday?
|
|
today
|
|
else
|
|
# Otherwise, default to next business day
|
|
next_business_day = today
|
|
loop do
|
|
next_business_day += 1
|
|
break if next_business_day.on_weekday?
|
|
end
|
|
next_business_day
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def update_letter_tags
|
|
letters.update_all(tags: tags)
|
|
end
|
|
|
|
def address_fields
|
|
# Only include address fields and rubber_stamps for letter mapping
|
|
["rubber_stamps"]
|
|
end
|
|
|
|
def build_mapping(row, address)
|
|
# Build letter with batch-level specs and extra data
|
|
letters.build(
|
|
height: letter_height,
|
|
width: letter_width,
|
|
weight: letter_weight,
|
|
processing_category: letter_processing_category,
|
|
recipient_email: row&.dig(field_mapping["email"]),
|
|
address: address,
|
|
usps_mailer_id: mailer_id,
|
|
return_address: letter_return_address,
|
|
return_address_name: letter_return_address_name,
|
|
rubber_stamps: row&.dig(field_mapping["rubber_stamps"]),
|
|
tags: tags,
|
|
user: user,
|
|
)
|
|
end
|
|
|
|
def generate_labels(options = {})
|
|
return unless letters.any?
|
|
|
|
# Preload associations to avoid N+1 queries
|
|
preloaded_letters = letters.includes(:address, :usps_mailer_id, :usps_indicium, :return_address)
|
|
|
|
# Build options for label generation
|
|
label_options = {}
|
|
|
|
# Add template information
|
|
if template_cycle.present?
|
|
label_options[:template_cycle] = template_cycle
|
|
elsif template.present?
|
|
label_options[:template] = template
|
|
end
|
|
|
|
# Use the SnailMail service to generate labels
|
|
pdf = SnailMail::PhlexService.generate_batch_labels(
|
|
preloaded_letters,
|
|
label_options.merge(options)
|
|
)
|
|
|
|
# Directly attach the PDF to this batch
|
|
attach_pdf(pdf.render)
|
|
|
|
# Return the PDF
|
|
pdf
|
|
end
|
|
end
|