require 'prawn'
require 'prawn/table'
require 'nokogiri'
# A class to produce a PDF for a single chapter
class ExportChapterPdf
include Prawn::View
THIRD_COUNTRY = '103'.freeze
TARIFF_PREFERENCE = '142'.freeze
CUSTOM_UNION_DUTY = '106'.freeze
PREFERENTIAL_MEASURE_TYPE_IDS = [ TARIFF_PREFERENCE, CUSTOM_UNION_DUTY ].freeze
MEASUREMENT_UNITS = ["% vol", "% vol/hl", "ct/l", "100 p/st", "c/k", "10 000 kg/polar", "kg DHS", "100 kg", "100 kg/net eda", "100 kg common wheat", "100 kg/br", "100 kg live weight", "100 kg/net mas", "100 kg std qual", "100 kg raw sugar", "100 kg/net/%sacchar.", "EUR", "gi F/S", "g", "GT", "hl", "100 m", "kg C₅H₁₄ClNO", "tonne KCl", "kg", "kg/tot/alc", "kg/net eda", "GKG", "kg/lactic matter", "kg/raw sugar", "kg/dry lactic matter", "1000 l", "kg methylamines", "KM", "kg N", "kg H₂O₂", "kg KOH", "kg K₂O", "kg P₂O₅", "kg 90% sdt", "kg NaOH", "kg U", "l alc. 100%", "l", "L total alc.", "1000 p/st", "1000 pa", "m²", "m³", "1000 m³", "m", "1000 kWh", "p/st", "b/f", "ce/el", "pa", "TJ", "1000 kg", "1000 kg/net eda", "1000 kg/biodiesel", "1000 kg/fuel content", "1000 kg/bioethanol", "1000 kg/net mas", "1000 kg std qual", "1000 kg/net/%saccha.", "Watt"].freeze
P_AND_R_MEASURE_TYPES_IMPORT = %w[277 705 724 745 410 420 465 474 475 707 710 712 714 722 728 730 746 747 748 750 755].freeze
P_AND_R_MEASURE_TYPES_EXPORT = %w[278 706 740 749 467 473 476 478 479 708 709 715 716 717 718 725 735 751].freeze
P_AND_R_MEASURE_TYPES_EXIM = %w[760 719].freeze
P_AND_R_MEASURE_TYPES = (P_AND_R_MEASURE_TYPES_IMPORT + P_AND_R_MEASURE_TYPES_EXIM + P_AND_R_MEASURE_TYPES_EXPORT).freeze
ANTIDUMPING_MEASURE_TYPES = ().freeze
SUPPORTED_CURRENCIES = {
'BGN' => 'лв',
'CZK' => 'Kč',
'DKK' => 'kr.',
'EUR' => '€',
'GBP' => '£',
'HRK' => 'kn',
'HUF' => 'Ft',
'PLN' => 'zł',
'RON' => 'lei',
'SEK' => 'kr'
}.freeze
CURRENCY_REGEX = /([0-9]+\.?[0-9]*)\s€/.freeze
CAP_LICENCE_KEY = 'CAP_LICENCE'
CAP_REFERENCE_TEXT = 'CAP licencing may apply. Specific licence requirements for this commodity can be obtained from the Rural Payment Agency website (www.rpa.gov.uk) under RPA Schemes.'
def initialize(opts = {})
@opts = opts
@chapter_id = opts[:chapter_id]
@margin = [50, 50, 20, 50]
@footer_height = 30
@printable_height = 595.28 - (@margin[0] + @margin[2])
@printable_width = 841.89 - (@margin[1] + @margin[3])
@base_table_font_size = 8
@indent_amount = 18
@document = Prawn::Document.new(
page_size: 'A4',
margin: @margin,
page_layout: :landscape
)
@cw = table_column_widths
@currency = set_currency
@currency_exchange_rate = fetch_exchange_rate
@footnotes = {}
@references_lookup = {}
@quotas = {}
@prs = {}
@anti_dumpings = {}
@pages_headings = {}
set_fonts
unless @chapter_id.to_s == 'test'
@chapter = Uktt::Chapter.new(@opts.merge(chapter_id: @chapter_id, version: 'v2')).retrieve
@section = Uktt::Section.new(@opts.merge(section_id: @chapter.data.relationships.section.data.id, version: 'v2')).retrieve
@current_heading = @section[:data][:attributes][:position]
end
bounding_box([0, @printable_height],
width: @printable_width,
height: @printable_height - @footer_height) do
if @chapter_id.to_s == 'test'
test
return
else
build
end
end
repeat(:all, dynamic: true) do
# trying to build a hash using page number as the key,
# but `#curent_heading` returns the last value, not the current value (i.e., when the footer is rendered)
if @pages_headings[page_number]
@pages_headings[page_number] << @current_heading
else
@pages_headings[page_number] = ['01', @current_heading]
end
page_footer
end
end
def set_currency
cur = (SUPPORTED_CURRENCIES.keys & [@opts[:currency]]).first
if cur = (SUPPORTED_CURRENCIES.keys & [@opts[:currency]]).first
return cur.upcase
else
raise StandardError.new "`#{@opts[:currency]}` is not a supported currency. SUPPORTED_CURRENCIES = [#{SUPPORTED_CURRENCIES.keys.join(', ')}]"
end
end
def set_fonts
font_families.update('OpenSans' => {
normal: 'vendor/assets/Open_Sans/OpenSans-Regular.ttf',
italic: 'vendor/assets/Open_Sans/OpenSans-RegularItalic.ttf',
medium: 'vendor/assets/Open_Sans/OpenSans-SemiBold.ttf',
medium_italic: 'vendor/assets/Open_Sans/OpenSans-SemiBoldItalic.ttf',
bold: 'vendor/assets/Open_Sans/OpenSans-Bold.ttf',
bold_italic: 'vendor/assets/Open_Sans/OpenSans-BoldItalic.ttf'
})
font_families.update('Monospace' => {
normal: 'vendor/assets/Overpass_Mono/OverpassMono-Regular.ttf',
bold: 'vendor/assets/Overpass_Mono/OverpassMono-Bold.ttf'
})
font 'OpenSans'
font_size @base_table_font_size
end
def fetch_exchange_rate(currency = @currency)
return 1.0 unless currency
return 1.0 if currency === Uktt::PARENT_CURRENCY
response = ENV.fetch("MX_RATE_EUR_#{currency}") do |_missing_name|
if currency === 'GBP'
Uktt::MonetaryExchangeRate.new(version: 'v2').latest(currency)
else
raise StandardError.new "Non-GBP currency exchange rates are not available via API and must be manually set with an environment variable, e.g., 'MX_RATE_EUR_#{currency}'"
end
end.to_f
return response if response > 0.0
raise StandardError.new "Currency error. response=#{response.inspect}"
end
def test
text "Today is #{Date.today}"
end
def build
if @chapter.data.attributes.goods_nomenclature_item_id[0..1] == @section.data.attributes.chapter_from
section_info
pad(16) { stroke_horizontal_rule }
start_new_page
end
chapter_info
move_down(12)
commodities_table
pad_top(24) do
font_size(13) do
pad_bottom(4) { text('Footnotes', inline_format: true) }
end
pad_bottom(4) { stroke_horizontal_rule }
footnotes
end
tariff_quotas
prohibitions_and_restrictions
anti_dumpings
end
def page_footer
bounding_box([0, @footer_height],
width: @printable_width,
height: @footer_height) do
table(footer_data, width: @printable_width) do |t|
t.column(0).align = :left
t.column(1).align = :center
t.column(2).align = :right
t.cells.borders = []
t.cells.padding = 0
end
end
end
def footer_data
# expecting something like this:
# `@pages_headings = {1=>["01", "02", "03", "04"], 2=>["04", "05", "06"]}`
footer_data_array = [[
format_text("#{Date.today.strftime('%-d %B %Y')}"),
format_text("#{@chapter.data.attributes.goods_nomenclature_item_id[0..1]}#{Prawn::Text::NBSP * 2}#{page_number}"),
format_text("Customs Tariff Vol 2 Sect #{@section.data.attributes.numeral}#{Prawn::Text::NBSP * 3}#{@chapter.data.attributes.goods_nomenclature_item_id[0..1]} #{@pages_headings[page_number].first.to_s.rjust(2, "0")}-#{@chapter.data.attributes.goods_nomenclature_item_id[0..1]} #{@pages_headings[page_number].last.to_s.rjust(2, "0")}")
]]
footer_data_array
end
def format_text(text_in, leading = 0)
{
content: text_in,
kerning: true,
inline_format: true,
leading: leading
}
end
def indents(note)
@this_indent ||= 0
@next_indent ||= 0
@top_pad ||= 0
case note
when /^\d\.\s/
@this_indent = 0
@next_indent = 12
@top_pad = @base_table_font_size / 2
when /\([a-z]\)\s/
@this_indent = 12
@next_indent = 24
@top_pad = @base_table_font_size / 2
when /\-\s/
@this_indent = 36
@next_indent = 36
@top_pad = @base_table_font_size / 2
else
@this_indent = @next_indent
@top_pad = 0
end
@this_indent
end
def hanging_indent(array, opts = {}, header = nil, leading = 0)
t = !header.nil? ? [[{ content: header, kerning: true, inline_format: true, colspan: 2, padding_bottom: 0 }, nil]] : []
make_table(
t << [
format_text(array[0], leading),
format_text(array[1], leading)
],
opts
) do |t|
t.cells.borders = []
t.column(0).padding_right = 0
t.row(0).padding_top = 0
end
end
def text_indent(note, opts)
if /
#{note.strip}", opts)
end
end
end
end
def section_info(section = @section)
section_note = section.data.attributes.section_note || ''
if section_note.length > 3200
opts = {
width: @printable_width / 3,
column_widths: [@indent_amount],
cell_style: {
padding_bottom: 0
},
inline_format: true,
}
column_box([0, cursor], columns: 3, width: bounds.width, height: (@printable_height - @footer_height - (@printable_height - cursor)), spacer: (@base_table_font_size * 3)) do
text("SECTION #{section.data.attributes.numeral}\n#{section.data.attributes.title}", opts)
move_down(@base_table_font_size * 1.5)
text('Notes', opts.merge(size: 10))
section_note.split(/\* /).each do |note|
text_indent(note.gsub(%r{\\.\s}, '. '), opts.merge(size: 10))
end
end
else
opts = {
width: @printable_width / 3,
column_widths: [@indent_amount],
cell_style: {
padding_bottom: 0
}
}
column_1 = format_text("SECTION #{section.data.attributes.numeral}\n#{section.data.attributes.title}")
_column_x, column_2, column_3 = get_notes_columns(section.data.attributes.section_note, opts, 'Notes', 10)
table(
[
[
column_1,
column_2,
column_3
]
],
column_widths: [@printable_width / 3, @printable_width / 3, @printable_width / 3]
) do |t|
t.cells.borders = []
t.column(0).padding_right = 12
t.row(0).padding_top = 0
end
end
end
def chapter_info(chapter = @chapter)
chapter_note = chapter.data.attributes.chapter_note || ''
notes, additional_notes, *everything_else = chapter_note.split(/#+\s*[Additional|Subheading]+ Note[s]*\s*#+/i)
.map do |s|
s.delete('\\')
.gsub("\r\n\r\n", "\r\n")
# .strip
end
notes ||= ''
if (additional_notes && chapter_note.length > 2300) || chapter_note.length > 3200
opts = {
kerning: true,
inline_format: true,
size: @base_table_font_size
}
column_box([0, cursor], columns: 3, width: bounds.width, height: (@printable_height - @footer_height - (@printable_height - cursor) + 20), spacer: (@base_table_font_size * 3)) do
text("Chapter #{chapter.data.attributes.goods_nomenclature_item_id[0..1].gsub(/^0/, '')}\n#{@chapter.data.attributes.formatted_description}", opts)
move_down(@base_table_font_size * 1.5)
text('Chapter notes', opts.merge(size: 9))
notes.split(/\* /).each do |note|
text_indent(note, opts.merge(size: 9))
end
move_down(@base_table_font_size)
if additional_notes
text('Subheading notes', opts)
move_down(@base_table_font_size / 2)
additional_notes && additional_notes.split(/\* /).each do |note|
text_indent(note, opts)
end
move_down(@base_table_font_size)
end
everything_else.each do |nn|
text('Additional notes', opts)
move_down(@base_table_font_size / 2)
nn.to_s.split(/\* /).each do |note|
text_indent(note, opts)
end
end
end
else
opts = {
width: @printable_width / 3,
column_widths: [(@indent_amount + 2)],
cell_style: {
padding_bottom: 0
}
}
column_x, column_2, column_3 = get_chapter_notes_columns(chapter.data.attributes.chapter_note, opts, 'Note', @chapter_notes_font_size)
column_1 = if column_x.empty? || (column_x[0] && column_x[0][0][:content].blank?)
format_text("Chapter #{chapter.data.attributes.goods_nomenclature_item_id[0..1].gsub(/^0/, '')}\n#{chapter.data.attributes.formatted_description}")
else
column_x
end
table(
[
[
column_1,
column_2,
column_3
]
],
column_widths: [@printable_width / 3, @printable_width / 3, @printable_width / 3]
) do |t|
t.cells.borders = []
t.column(0).padding_right = 12
t.row(0).padding_top = 0
end
end
end
def html_table_data(html)
noko = Nokogiri::HTML(html)
head = noko.at('th') ? noko.at('th').content : nil
data = noko.css('tr').map do |tr|
tr.css('td').map(&:content)
end
max_col_count = data.map(&:length).max
data_normalized = data.reject do |row|
row.length != max_col_count
end
return data_normalized.unshift([{content: head, colspan: max_col_count}]) if head
data_normalized
end
def strip_tags(text)
return if text.nil?
noko = Nokogiri::HTML(text)
noko.css('span', 'abbr').each { |node| node.replace(node.children) }
noko.content
end
def render_html_table(html)
html_string = ""
table(html_table_data(html_string), cell_style: {
padding: 2,
size: 5,
border_widths: [0.1, 0.1]
} ) do |t|
t.width = @printable_width / 3
end
end
def update_footnotes(v2_commodity)
measures = commodity_measures(v2_commodity)
measure_footnote_ids = measures.map{|m| m.relationships.footnotes.data}.flatten.uniq.map(&:id)
commodity_footnote_ids = v2_commodity.data.relationships.footnotes.data.flatten.uniq.map(&:id)
footnotes = (commodity_footnote_ids + measure_footnote_ids).map do |f|
v2_commodity.included.select{|obj| obj.id == f}
end.flatten
footnotes.each do |fn|
f = fn.attributes
next if f.code =~ /0[3,4]./
if @footnotes[f.code]
@footnotes[f.code][:refs] << @uktt.response.data.id
else
@footnotes[f.code] = {
text: "#{f.code}-#{render_footnote(f.description)}",
refs: [@uktt.response.data.id]
}
unless @references_lookup[footnote_reference_key(f.code)]
@references_lookup[footnote_reference_key(f.code)] = {
index: @references_lookup.length + 1,
text: replace_html(@footnotes[f.code][:text].delete('|'))
}
end
end
end
end
def render_footnote(note)
Nokogiri::HTML(note).css('p').map(&:content).join("\n")
end
def update_quotas(v2_commodity, heading)
quotas = commodity_measures(v2_commodity).select{|m| measure_is_quota(m)}
quotas.each do |measure_quota|
order_number = measure_quota.relationships.order_number.data.id
if @quotas[order_number]
@quotas[order_number][:measures] << measure_quota
@quotas[order_number][:commodities] << v2_commodity.data.attributes.goods_nomenclature_item_id
else
duty = v2_commodity.included.select{|obj| measure_quota.relationships.duty_expression.data.id == obj.id}.first.attributes.base
definition_relation = v2_commodity.included.select{|obj| measure_quota.relationships.order_number.data.id == obj.id}.first.relationships.definition
return if definition_relation.data.nil?
definition = v2_commodity.included.select{|obj| definition_relation.data.id == obj.id}.first
footnotes_ids = measure_quota.relationships.footnotes.data.map(&:id).select{|f| f[0..1] == 'CD'}
footnotes = v2_commodity.included.select{|obj| footnotes_ids.include?(obj.id)}
@quotas[order_number] = {
commodities: [v2_commodity.data.attributes.goods_nomenclature_item_id],
descriptions: [[heading.description, v2_commodity.data.attributes.description]],
measures: [measure_quota],
duties: [duty],
definitions: [definition],
footnotes: footnotes
}
end
end
end
def update_prs(v2_commodity)
measures = pr_measures(v2_commodity)
measures.each do |measure|
document_codes = []
requirements = []
id = measure.relationships.measure_type.data.id
if @prs[id]
@prs[id][:commodities] << v2_commodity.data.attributes.goods_nomenclature_item_id
else
desc = v2_commodity.included.select{|obj| obj.type = "measure_type" && obj.id == id}.first.attributes.description
conditions_ids = measure.relationships.measure_conditions.data.map(&:id)
conditions = v2_commodity.included.select{|obj| obj.type = "measure_condition" && conditions_ids.include?(obj.id) }
conditions.each do |condition|
unless condition.nil?
doc_code = condition.attributes.document_code
document_codes << condition.attributes.document_code
requirements << "#{condition.attributes.condition_code}: #{strip_tags(condition.attributes.requirement)}#{" (#{doc_code})" unless doc_code.to_s.empty?}" unless condition.attributes.requirement.nil?
end
end
@prs[id] = {
measures: measure,
commodities: [v2_commodity.data.attributes.goods_nomenclature_item_id],
description: "#{desc} (#{id})",
conditions: document_codes.reject(&:empty?),
requirements: requirements.reject(&:nil?),
}
end
end
end
def update_anti_dumpings(v2_commodity)
anti_dumping_measures(v2_commodity).each do |measure|
description = ''
delimiter = ''
duty_expression_id = measure.relationships.duty_expression&.data&.id
if duty_expression_id
duty_expression = find_duty_expression(duty_expression_id)
unless duty_expression&.attributes&.base == ''
measure_type = find_measure_type(measure.relationships.measure_type&.data&.id)
description += clean_rates(duty_expression&.attributes&.base) + '
' + measure_type&.attributes&.description
delimiter = '
'
end
end
additional_code_id = measure.relationships.additional_code&.data&.id
if additional_code_id
additional_code = find_additional_code(additional_code_id)
description += delimiter + additional_code.attributes.formatted_description
end
unless description == ''
commodity_item_id = v2_commodity.data.attributes.goods_nomenclature_item_id
geographical_area_id = measure.relationships.geographical_area.data.id
@anti_dumpings[commodity_item_id] ||= {}
@anti_dumpings[commodity_item_id][geographical_area_id] ||= {}
@anti_dumpings[commodity_item_id][geographical_area_id][additional_code&.attributes&.code || ''] ||= description
end
end
end
def find_measure_type(measure_type_id)
find_included_object(measure_type_id, 'measure_type')
end
def find_duty_expression(duty_expression_id)
find_included_object(duty_expression_id, 'duty_expression')
end
def find_additional_code(additional_code_id)
find_included_object(additional_code_id, 'additional_code')
end
def find_included_object(object_id, object_type)
return nil unless object_id || object_type
@uktt.response.included.find do |obj|
obj.id == object_id && obj.type == object_type
end
end
def commodities_table
table commodity_table_data, header: true, column_widths: @cw do |t|
t.cells.border_width = 0.25
t.cells.borders = %i[left right]
t.cells.padding_top = 2
t.cells.padding_bottom = 2
t.row(0).align = :center
t.row(0).padding = 2
t.column(1).align = :center
t.column(2).align = :center
t.column(5).align = :center
t.row(0).borders = %i[top right bottom left]
t.row(-1).borders = %i[right bottom left]
end
end
def commodity_table_data(chapter = @chapter)
result = [] << header_row
heading_ids = chapter.data.relationships.headings.data.map(&:id)
heading_objs = chapter.included.select{|obj| heading_ids.include? obj.id}
heading_gniids = heading_objs.map{|h| h.attributes.goods_nomenclature_item_id}.uniq.sort
heading_gniids.each do |heading_gniid|
@uktt = Uktt::Heading.new(@opts.merge(heading_id: heading_gniid[0..3], version: 'v2'))
v2_heading = @uktt.retrieve
heading = v2_heading.data.attributes
if heading.declarable
update_footnotes(v2_heading)
update_quotas(v2_heading, heading)
update_prs(v2_heading)
update_anti_dumpings(v2_heading)
end
result << heading_row_head(v2_heading)
result << heading_row_title(v2_heading)
# You'd think this would work, but `page_number` is not updated
# because we're not inside the `repeat` block
#
# if @pages_headings[page_number]
# @pages_headings[page_number] << @current_heading
# else
# @pages_headings[page_number] = [@current_heading]
# end
# logger.info @pages_headings.inspect
# Same with below, but when trying to get the value of `@current_heading`
# in the `repeat` block, it always returns the last value, not the current
@current_heading = heading.goods_nomenclature_item_id[2..3]
if v2_heading.data.relationships.commodities
commodity_ids = v2_heading.data.relationships.commodities.data.map(&:id)
commodity_objs = v2_heading.included.select{|obj| commodity_ids.include? obj.id}
commodity_objs.each do |c|
if c.attributes.leaf
@uktt = Uktt::Commodity.new(@opts.merge(commodity_id: c.attributes.goods_nomenclature_item_id, version: 'v2'))
v2_commodity = @uktt.retrieve
if v2_commodity.data
result << commodity_row(v2_commodity)
v2_commodity.data.attributes.description = c.attributes.description
update_footnotes(v2_commodity) if v2_commodity.data.attributes.declarable
update_quotas(v2_commodity, heading)
update_prs(v2_commodity)
update_anti_dumpings(v2_commodity)
else
result << commodity_row_subhead(c)
end
else
result << commodity_row_subhead(c)
end
end
end
end
result
end
def header_row
%w[1 2A 2B 3 4 5 6 7]
end
def heading_row_head(v2_heading)
heading = v2_heading.data.attributes
head = {
content: "#{heading[:goods_nomenclature_item_id][0..1]} #{heading[:goods_nomenclature_item_id][2..3]}",
kerning: true,
size: 12,
borders: [],
padding_bottom: 0,
inline_format: true
}
[head, '', '', '', '', '', '', '']
end
def heading_row_title(v2_heading)
heading = v2_heading.data.attributes
title = {
content: "#{heading[:description].gsub('|', Prawn::Text::NBSP).upcase}",
kerning: true,
size: @base_table_font_size,
width: @cw[0],
borders: [],
padding_top: 0,
inline_format: true
}
if heading.declarable
heading_data = [
commodity_code_cell(heading), # Column 2A: Commodity code, 8 digits, center-align
additional_commodity_code_cell(heading), # Column 2B: Additional commodity code, 2 digits, center-align
specific_provisions(v2_heading), # Column 3: Specific provisions, left-align
units_of_quantity_list, # Column 4: Unit of quantity, numbered list, left-align
third_country_duty_expression, # Column 5: Full tariff rate, percentage, center align
preferential_tariffs, # Column 6: Preferential tariffs, left align
formatted_vat_rate_cell # Column 7: VAT Rate: e.g., 'S', 'Z', etc., left align
]
else
heading_data = ['', '', '', '', '', '', '']
end
[[[title]]] + heading_data
end
def commodity_row(v2_commodity)
commodity = v2_commodity.data.attributes
[
formatted_heading_cell(commodity), # Column 1: Heading numbers and descriptions
commodity_code_cell(commodity), # Column 2A: Commodity code, 8 digits, center-align
additional_commodity_code_cell(commodity), # Column 2B: Additional commodity code, 2 digits, center-align
specific_provisions(v2_commodity), # Column 3: Specific provisions, left-align
units_of_quantity_list, # Column 4: Unit of quantity, numbered list, left-align
third_country_duty_expression, # Column 5: Full tariff rate, percentage, center align
preferential_tariffs, # Column 6: Preferential tariffs, left align
formatted_vat_rate_cell # Column 7: VAT Rate: e.g., 'S', 'Z', etc., left align
]
end
def commodity_row_subhead(c)
commodity = c.attributes
[
formatted_heading_cell(commodity),
commodity_code_cell(commodity),
additional_commodity_code_cell(commodity),
'',
'',
'',
'',
''
]
end
def formatted_heading_cell(commodity)
indents = (('-' + Prawn::Text::NBSP) * (commodity.number_indents - 1)) # [(commodity.number_indents - 1), 1].max)
opts = {
width: @cw[0],
column_widths: { 0 => ((commodity.number_indents || 1) * 5.1) }
}
footnotes_array = []
@footnotes.each_pair do |k, v|
if @uktt.response.data && v[:refs].include?(@uktt.response.data.id) && k[0..1] != 'CD'
footnotes_array << @references_lookup[footnote_reference_key(k)][:index]
end
end
if footnotes_array.empty?
footnote_references = ""
leading = 0
else
footnote_references = " [#{footnotes_array.join(',')}]"
leading = 4
end
# TODO: implement Commodity#from_harmonized_system? and Commodity#in_combined_nomenclature?
# i.e.: (see below)
# if commodity.from_harmonized_system? || commodity[:number_indents] <= 1
# content = format_text("#{commodity.description}#{footnote_references}")
# elsif commodity.in_combined_nomenclature?
# content = hanging_indent(["#{indents}", "#{commodity.description}#{footnote_references}"], opts)
# else
# content = hanging_indent([indents, "#{commodity.description}#{footnote_references}"], opts)
# end
description = render_special_characters(commodity.description)
if commodity.number_indents.to_i <= 1 #|| !commodity.declarable
format_text("#{description}<#{footnote_references}", leading)
elsif commodity.declarable
hanging_indent(["#{indents}", "#{description}#{footnote_references}"], opts, nil, leading)
elsif commodity.number_indents.to_i == 2
hanging_indent([indents, "#{description}#{footnote_references}"], opts, nil, leading)
else
hanging_indent([indents, "#{description}#{footnote_references}"], opts, nil, leading)
end
end
def render_special_characters(string)
string.gsub( /@([2-9])/, '\1 ' )
.gsub( /\|/, Prawn::Text::NBSP )
end
def commodity_code_cell(commodity)
return '' unless commodity.declarable
format_text "#{commodity.goods_nomenclature_item_id[0..5]}#{Prawn::Text::NBSP * 1}#{commodity.goods_nomenclature_item_id[6..7]}"
end
def additional_commodity_code_cell(commodity)
return '' unless commodity.declarable
format_text "#{(commodity.goods_nomenclature_item_id[8..9]).to_s}"
end
# copied from backend/app/models/measure_type.rb:41
def measure_type_excise?(measure_type)
measure_type&.attributes&.measure_type_series_id == 'Q'
end
def measure_type_anti_dumping?(measure_type)
measure_type&.attributes&.measure_type_series_id == 'D'
end
def anti_dumping_measure_type_ids
@uktt.response.included.select do |obj|
obj.type == 'measure_type' && measure_type_anti_dumping?(obj)
end.map(&:id)
end
def measure_type_tax_code(measure_type)
measure_type.attributes.description.scan(/\d{3}/).first
end
def measure_type_suspension?(measure_type)
measure_type&.attributes&.description =~ /suspension/
end
def measure_conditions_has_cap_license?(measure_conditions)
measure_conditions.any? do |measure_condition|
measure_condition&.attributes&.document_code == 'L001'
end
end
def specific_provisions(v2_commodity)
return '' unless v2_commodity.data.attributes.declarable
measures = commodity_measures(v2_commodity)
measure_types = measures.map do |measure|
v2_commodity.included.find {|obj| obj.id == measure.relationships.measure_type.data.id && obj.type == 'measure_type'}
end
excise_codes = measure_types.select(&method(:measure_type_excise?)).map(&method(:measure_type_tax_code)).uniq.sort
str = excise_codes.length > 0 ? "EXCISE (#{excise_codes.join(', ')})" : ''
delimiter = str.length > 0 ? "\n" : ''
str += measure_types.select(&method(:measure_type_suspension?)).length > 0 ? delimiter + 'S' : ''
delimiter = str.length > 0 ? "\n" : ''
str += (measures.select(&method(:measure_is_quota)).length > 0 ? delimiter + 'TQ' : '')
delimiter = str.length > 0 ? "\n" : ''
measure_conditions = measures.map do |measure|
v2_commodity.included.find { |obj| measure.relationships.measure_conditions.data.map(&:id).include?(obj.id) && obj.type == 'measure_condition' }
end.compact.uniq
if measure_conditions_has_cap_license?(measure_conditions)
unless @references_lookup[CAP_LICENCE_KEY]
@references_lookup[CAP_LICENCE_KEY] = {
index: @references_lookup.length + 1,
text: CAP_REFERENCE_TEXT
}
end
str += delimiter + "CAP Lic [#{@references_lookup[CAP_LICENCE_KEY][:index]}]"
end
format_text(str, 0)
end
def units_of_quantity_list
str = ''
duties = @uktt.find('duty_expression').map{ |d| d.attributes.base }
return str if duties.empty?
uoq = ['Kg']
duties.each do |duty|
uoq << duty if MEASUREMENT_UNITS.include?(duty)
end
uoq.each_with_index do |q, i|
str << "#{(i + 1).to_s + '. ' if uoq.length > 1}#{q}\n"
end
str
end
def third_country_duty_expression
measure = @uktt.find('measure').select{|m| m.relationships.measure_type.data.id == THIRD_COUNTRY }.first
return '' if measure.nil?
clean_rates(@uktt.find(measure.relationships.duty_expression.data.id).attributes.base)
end
def preferential_tariffs
preferential_tariffs = {
duties: {},
footnotes: {},
excluded: {},
}
s = []
@uktt.find('measure').select{|m| PREFERENTIAL_MEASURE_TYPE_IDS.include?(m.relationships.measure_type.data.id) }.each do |t|
g_id = t.relationships.geographical_area.data.id
geo = @uktt.response.included.select{|obj| obj.id == g_id}.map{|t| t.id =~ /[A-Z]{2}/ ? t.id : t.attributes.description}.join(', ')
d_id = t.relationships.duty_expression.data.id
duty = @uktt.response.included.select{|obj| obj.id == d_id}.map{|t| t.attributes.base}
f_ids = t.relationships.footnotes.data.map(&:id)
footnotes = @uktt.response.included.select{|obj| f_ids.include? obj.id}.flatten
x_ids = t.relationships.excluded_countries.data.map(&:id)
excluded = @uktt.response.included.select{|obj| x_ids.include? obj.id}
footnotes_string = footnotes.map(&:id).map{|fid| "[#{@references_lookup.dig(footnote_reference_key(fid), :index)}]"}.join(' ')
excluded_string = excluded.map(&:id).map{|xid| " (Excluding #{xid})"}.join(' ')
duty_string = clean_rates(duty.join, column: 6)
s << "#{geo}#{excluded_string}-#{duty_string}#{footnotes_string}"
end
{ content: s.sort.join(', '), inline_format: true }
end
def formatted_vat_rate_cell
@uktt.find('measure_type')
.map(&:id)
.select{|id| id[0..1] == 'VT'}
.map{|m| m.chars[2].upcase}
.join(' ')
end
def footnotes
return if @footnotes.size == 0
cell_style = {
padding: 0,
borders: []
}
table_opts = {
column_widths: [25],
width: @printable_width,
cell_style: cell_style
}
notes_array = @references_lookup.map do |_, reference|
[ "( #{reference[:index]} )", reference[:text] ]
end
table notes_array, table_opts do |t|
t.column(1).padding_left = 5
end
end
def replace_html(raw)
raw.gsub(//, "\n")
.gsub(%r{
}, '')
.gsub('&', '&')
# .gsub("\n\n", "\n")
end
def tariff_quotas(chapter = @chapter)
cell_style = {
padding: 0,
borders: [],
inline_format: true
}
table_opts = {
column_widths: quota_table_column_widths,
width: @printable_width,
cell_style: cell_style
}
quotas_array = quota_header_row
@quotas.each do |measure_id, quota|
commodity_ids = quota[:commodities].uniq
while commodity_ids.length > 0
quotas_array << [
quota_commodities(commodity_ids.shift(quotas_array.length == 2 ? 42 : 56)),
quota_description(quota[:descriptions]),
quota_geo_description(quota[:measures]),
measure_id,
quota_rate(quota[:duties]),
quota_period(quota[:measures]),
quota_units(quota[:definitions]),
quota_docs(quota[:footnotes])
]
end
end
unless quotas_array.length <= 2
start_new_page
font_size(19) do
text "Chapter #{chapter.data.attributes.goods_nomenclature_item_id[0..1].gsub(/^0/, '')}#{Prawn::Text::NBSP * 4}Additional Information",
inline_format: true
end
font_size(13) do
pad_bottom(13) do
text 'Tariff Quotas/Ceilings',
inline_format: true
end
end
table quotas_array, table_opts do |t|
t.cells.border_width = 0.25
t.cells.borders = %i[top bottom]
t.cells.padding_top = 2
t.cells.padding_bottom = 4
t.cells.padding_right = 9
t.row(0).border_width = 1
t.row(0).borders = [:top]
t.row(1).borders = [:bottom]
t.row(0).padding_top = 0
t.row(0).padding_bottom = 0
t.row(1).padding_top = 0
t.row(1).padding_bottom = 2
end
end
end
def quota_header_row
[
[
format_text('Commodity Code'),
format_text('Description'),
format_text('Country of origin'),
format_text('Tariff Quota Order No.'),
format_text('Quota rate'),
format_text('Quota period'),
format_text('Quota units'),
format_text("Documentary evidence\nrequired")
],
(1..8).to_a
]
end
def quota_commodities(commodities)
commodities.map do |c|
[
c[0..3],
c[4..5],
c[6..7],
c[8..-1]
].reject(&:empty?).join(Prawn::Text::NBSP)
end.join("\n")
end
def quota_description(descriptions)
# descriptions.flatten.join(' - ')
descriptions.flatten[1]
end
def quota_geo_description(measures)
measures.map do |measure|
if @uktt.response.included
geos = @uktt.response.included.select{|obj| obj.id == measure.relationships.geographical_area.data.id}
geos.first.attributes.description unless geos.first.nil?
end
end.uniq.join(', ')
end
def quota_rate(duties)
clean_rates(duties.uniq.join(', '))
end
def quota_period(measures)
formatted_date = '%d/%m/%Y'
measures.map do |m|
start = m.attributes.effective_start_date ? DateTime.parse(m.attributes.effective_start_date).strftime(formatted_date) : ''
ending = m.attributes.effective_end_date ? DateTime.parse(m.attributes.effective_end_date).strftime(formatted_date) : ''
"#{start} - #{ending}"
end.uniq.join(', ')
end
def quota_units(definitions)
definitions.map do |d|
d.attributes.measurement_unit
end.uniq.join(', ')
end
def quota_docs(footnotes)
return '' if footnotes.empty?
footnotes.map do |f|
f.attributes.description
end.uniq.join(', ')
end
def get_chapter_notes_columns(content, opts, header_text = 'Note', _font_size = 9)
get_notes_columns(content, opts, header_text, 9, 2)
end
def notes_str_to_note_array(notes_str)
notes = []
note_tmp = split_note(notes_str)
while note_tmp.length >= 2
notes << note_tmp[0..1]
note_tmp = note_tmp[2..-1]
end
notes << note_tmp
end
def get_notes_columns(content, opts, header_text = 'Note', font_size = @base_table_font_size, fill_columns = 2)
empty_cell = [{ content: '', borders: [] }]
return [[empty_cell, empty_cell, empty_cell]] if content.nil?
column_1 = []
column_2 = []
column_3 = []
notes_str = content.delete('\\')
notes = notes_str_to_note_array(notes_str)
title = "Chapter #{@chapter.data.attributes.goods_nomenclature_item_id[0..1].gsub(/^0/, '')}\n#{@chapter[:formatted_description]}\n\n"
offset = 0
notes.each_with_index do |note, i|
m = note.join.match(/##\s*(additional|subheading) note[s]*\s*##/i)
if m
note[0], note[1] = '', ''
header = "#{fill_columns == 3 ? title : nil}#{"#{m[1]} Note"}"
offset += 1
else
header = i.zero? ? "#{fill_columns == 3 ? title : nil}#{header_text}" : nil
end
new_note = [
{
content: hanging_indent([
"#{note[0]}",
"#{note[1]}"
], opts, header),
borders: []
}
]
if fill_columns == 2
if i - offset < (notes.length / 2)
column_2 << new_note unless new_note == ['', '']
else
column_3 << new_note
end
elsif fill_columns == 3
if i < (notes.length / 3)
column_1 << new_note
elsif i < ((notes.length / 3) * 2)
column_2 << new_note
else
column_3 << new_note
end
end
end
column_2 << empty_cell if column_2.empty?
column_3 << empty_cell if column_3.empty?
[column_1, column_2, column_3]
end
def split_note(str)
arr = str.split(/\* |^([0-9]\.{0,}\s|\([a-z]{1,}\))|(?=##\ )/)
.map { |n| n.split(/^([0-9]\.{0,}\s{0,}|\([a-z]{1,}\))/) }
.each { |n| n.unshift(Prawn::Text::NBSP) if n.length == 1 }
.flatten
.reject(&:empty?)
.map(&:strip)
return arr.unshift((Prawn::Text::NBSP * 2)) if arr.length == 1
normalize_notes_array(arr)
end
def token?(str)
str =~ /^[0-9]\.{0,}\s{0,}|\([a-z]{1,}\)|\s{1,}/
end
def normalize_notes_array(arr)
arr.each_with_index do |str, i|
if str == Prawn::Text::NBSP && i.odd?
arr.delete_at(i)
normalize_notes_array(arr)
end
end
end
def table_column_widths
column_ratios = [21, 5, 1.75, 5, 4, 5.25, 19, 2]
multiplier = @printable_width / column_ratios.sum
column_ratios.map { |n| n * multiplier }
end
def quota_table_column_widths
column_ratios = [12, 43, 9, 9, 11, 11, 8, 22]
multiplier = 741.89 / column_ratios.sum
column_ratios.map { |n| n * multiplier }
end
def pr_table_column_widths
column_ratios = [2, 1, 4, 4, 1]
multiplier = 741.89 / column_ratios.sum
column_ratios.map { |n| n * multiplier }
end
def anti_dumping_table_column_widths
column_ratios = [1, 1, 1, 4]
multiplier = 741.89 / column_ratios.sum
column_ratios.map { |n| n * multiplier }
end
def clean_rates(raw, column: nil)
rate = raw
if column != 6
rate = rate.gsub(/^0.00 %/, 'Free')
end
rate = rate.gsub(' EUR ', ' € ')
.gsub(' / ', '/')
.gsub(/(\.[0-9]{1})0 /, '\1 ')
.gsub(/([0-9]{1})\.0 /, '\1 ')
CURRENCY_REGEX.match(rate) do |m|
rate = rate.gsub(m[0], "#{convert_currency(m[1])} #{currency_symbol} ")
end
rate
end
def commodity_measures(commodity)
ids = commodity.data.relationships.import_measures.data.map(&:id) + commodity.data.relationships.export_measures.data.map(&:id)
commodity.included.select{|obj| ids.include? obj.id}
end
def measure_is_quota(measure)
!measure.relationships.order_number.data.nil?
end
def measure_footnotes(measure)
measure.relationships.footnotes.data.map
end
def measure_duty_expression(measure)
measure.relationships.duty_expression.data
end
def pr_measures(v2_commodity)
# c = Uktt::Commodity.new(commodity_id: '3403910000')
# v2 = c.retrieve
v2_commodity.included.select{|obj| obj.type == 'measure' && measure_is_pr(obj)}
end
def anti_dumping_measures(v2_commodity)
anti_dumping_ids = anti_dumping_measure_type_ids
v2_commodity.included.select{ |obj| obj.type == 'measure' && anti_dumping_ids.include?(obj.relationships.measure_type.data.id) }
end
def measure_is_pr(measure)
P_AND_R_MEASURE_TYPES.include?(measure.relationships.measure_type.data.id)
end
def prohibitions_and_restrictions
cell_style = {
padding: 0,
borders: [],
inline_format: true
}
table_opts = {
column_widths: pr_table_column_widths,
width: @printable_width,
cell_style: cell_style
}
prs_array = pr_header_row
@prs.each do |id, pr|
commodity_ids = pr[:commodities].uniq
while commodity_ids.length > 0
prs_array << [
quota_commodities(commodity_ids.shift(prs_array.length == 2 ? 46 : 56)),
pr[:measures].attributes.import ? "Import" : "Export", # Import/Export
pr[:description], # Description, was Measure Type Code
pr[:requirements].join("
"), # Requirements, was Measure Group Code
pr[:conditions].join("
"), # Document Code/s
# '', # Ex-heading Indicator
]
end
end
unless prs_array.length <= 2 || false
start_new_page
font_size(19) do
text "Chapter #{@chapter.data.attributes.goods_nomenclature_item_id[0..1].gsub(/^0/, '')}#{Prawn::Text::NBSP * 4}Additional Information",
inline_format: true
end
font_size(13) do
pad_bottom(13) do
text 'Prohibitions and Restrictions',
inline_format: true
end
end
table prs_array, table_opts do |t|
t.cells.border_width = 0.25
t.cells.borders = %i[top bottom]
t.cells.padding_top = 4
t.cells.padding_bottom = 6
t.cells.padding_right = 9
t.row(0).border_width = 1
t.row(0).borders = [:top]
t.row(1).borders = [:bottom]
t.row(0).padding_top = 0
t.row(0).padding_bottom = 0
t.row(1).padding_top = 0
t.row(1).padding_bottom = 2
end
end
end
def pr_header_row
[
[
format_text('Commodity Code'),
format_text('Import/ Export'),
format_text('Description'), # format_text('Measure Type Code'),
format_text('Requirements'), # format_text('Measure Group Code'),
format_text('Document Code/s'),
# format_text('Ex-heading Indicator')
],
(1..5).to_a
]
end
def anti_dumpings
return if @anti_dumpings.empty?
# group commodities by goods nomenclature item id and additional codes
grouped = @anti_dumpings.group_by do |_, value|
value.keys.sort.map do |k|
"#{k.to_s}_#{value[k].keys.sort.join('_')}"
end.join('_')
end.map do |_, value|
{ value.map(&:first) => value.first.last }
end.inject({}, &:merge)
output = anti_dumping_header_row
# represent each line from grouped data as 3+ rows - 1st goods nomenclatures, 2nd geo area id + 1st info row, 3rd and next - rest of the rows with info
output += grouped.map do |goods_nomenclature_item_ids, data|
[
# 1st row
[ make_cell(quota_commodities(goods_nomenclature_item_ids), borders: []), make_cell("", borders: []), make_cell("", borders: []), make_cell("", borders: []) ],
].concat(
data.map do |geographical_area_id, additional_codes|
[
# 2nd row
[ make_cell("", borders: []), make_cell(geographical_area_id, borders: []), make_cell(additional_codes.first.first, borders: []), make_cell(additional_codes.first.last, borders: []) ]
].concat(
# 3rd and next, show additional_code_id only on first line only
additional_codes.drop(1).map do |additional_code_id, description|
description.split(/
/).map do |description_line|
borders = description.index(description_line) === 0 ? [:top] : []
additional_code_text = description.index(description_line) === 0 ? additional_code_id : ""
[ make_cell("", borders: []), make_cell("", borders: []), make_cell(additional_code_text, borders: borders), make_cell(description_line, borders: borders) ]
end
end.flatten(1)
).push([ make_cell("", borders: []),
make_cell("", { borders: %i[bottom] }),
make_cell("", { borders: %i[bottom] }),
make_cell("", { borders: %i[bottom] }) ]
)
end.flatten(1)
).tap(&:pop).push([ make_cell("", { borders: %i[bottom] }),
make_cell("", { borders: %i[bottom] }),
make_cell("", { borders: %i[bottom] }),
make_cell("", { borders: %i[bottom] }) ]
)
end.flatten(1)
start_new_page
font_size(19) do
text "Chapter #{@chapter.data.attributes.goods_nomenclature_item_id[0..1].gsub(/^0/, '')}#{Prawn::Text::NBSP * 4}Additional Information",
inline_format: true
end
font_size(13) do
pad_bottom(13) do
text 'Anti-dumping duties',
inline_format: true
end
end
cell_style = {
padding: 0,
inline_format: true
}
table_opts = {
column_widths: anti_dumping_table_column_widths,
width: @printable_width,
cell_style: cell_style
}
table output, table_opts do |t|
t.cells.border_width = 0.25
t.cells.padding_right = 9
t.row(0).border_width = 1
t.row(0).borders = [:top]
t.row(1).borders = [:bottom]
t.row(0).padding_top = 0
t.row(0).padding_bottom = 0
t.row(1).padding_top = 0
t.row(1).padding_bottom = 2
output[2..-1].each_with_index do |line, i|
t.row(i + 2).padding_top = "#{line[0]}#{line[1]}#{line[2]}" == '' ? 0 : 6
end
end
end
def anti_dumping_header_row
[
[
format_text('Commodity Code'),
format_text('Country of Origin'),
format_text('Additional Code'),
format_text('Description/Rate of Duty/Additional Information'),
],
(1..4).to_a
]
end
def convert_currency(amount, precision = 1)
(amount.to_f * @currency_exchange_rate).round(precision)
end
def currency_symbol
return '€' unless @currency
SUPPORTED_CURRENCIES[@currency]
end
UNIT_ABBREVIATIONS = {
'Number of items'.to_sym => 'Number',
'Hectokilogram'.to_sym => 'Kg'
}.freeze
RECIPIENT_SHORTENER = {
# 'EU-Canada agreement: re-imported goods'.to_sym => 'EU-CA',
# 'Economic Partnership Agreements'.to_sym => 'EPA',
# 'Eastern and Southern Africa States'.to_sym => 'ESAS',
# 'GSP (R 12/978) - Annex IV'.to_sym => 'GSP-AX4',
# 'OCTs (Overseas Countries and Territories)'.to_sym => 'OCT',
# 'GSP+ (incentive arrangement for sustainable development and good governance)'.to_sym => 'GSP+',
# 'SADC EPA'.to_sym => 'SADC',
# 'GSP (R 12/978) - General arrangements'.to_sym => 'GSP-GA',
# 'GSP (R 01/2501) - General arrangements'.to_sym => 'GSP',
# 'Central America'.to_sym => 'CEN-AM',
}.freeze
private
def footnote_reference_key(footnote_code)
"FOOTNOTE-#{footnote_code}"
end
end