facturx 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/NOTICE.md +2 -0
- data/README.md +27 -1
- data/lib/facturx/coerce.rb +74 -0
- data/lib/facturx/diagnostic.rb +20 -0
- data/lib/facturx/document.rb +45 -0
- data/lib/facturx/group.rb +5 -0
- data/lib/facturx/model/common.rb +8 -0
- data/lib/facturx/model/immutable.rb +75 -0
- data/lib/facturx/model/line.rb +14 -0
- data/lib/facturx/model/party.rb +10 -0
- data/lib/facturx/model/payment.rb +9 -0
- data/lib/facturx/model/reference.rb +7 -0
- data/lib/facturx/model/trade.rb +9 -0
- data/lib/facturx/model.rb +9 -0
- data/lib/facturx/pdf/extractor.rb +7 -4
- data/lib/facturx/reader/semantic_mapper/delivery_mapping.rb +43 -0
- data/lib/facturx/reader/semantic_mapper/document_mapping.rb +109 -0
- data/lib/facturx/reader/semantic_mapper/helpers.rb +114 -0
- data/lib/facturx/reader/semantic_mapper/line_mapping.rb +120 -0
- data/lib/facturx/reader/semantic_mapper/party_mapping.rb +90 -0
- data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +71 -0
- data/lib/facturx/reader/semantic_mapper/trade_mapping.rb +83 -0
- data/lib/facturx/reader/semantic_mapper.rb +67 -0
- data/lib/facturx/reader/term_reader/coercion.rb +69 -0
- data/lib/facturx/reader/term_reader/unmapped.rb +37 -0
- data/lib/facturx/reader/term_reader.rb +124 -0
- data/lib/facturx/reader.rb +101 -0
- data/lib/facturx/reading.rb +23 -0
- data/lib/facturx/source_reader.rb +30 -0
- data/lib/facturx/term.rb +5 -0
- data/lib/facturx/terms/declaration.rb +20 -0
- data/lib/facturx/terms/declarations/adjustments.rb +230 -0
- data/lib/facturx/terms/declarations/delivery.rb +83 -0
- data/lib/facturx/terms/declarations/document.rb +223 -0
- data/lib/facturx/terms/declarations/groups.rb +356 -0
- data/lib/facturx/terms/declarations/lines.rb +166 -0
- data/lib/facturx/terms/declarations/parties.rb +609 -0
- data/lib/facturx/terms/declarations/payment.rb +106 -0
- data/lib/facturx/terms/declarations/products.rb +103 -0
- data/lib/facturx/terms/declarations/references.rb +72 -0
- data/lib/facturx/terms/declarations/taxes.rb +92 -0
- data/lib/facturx/terms/declarations/totals.rb +107 -0
- data/lib/facturx/terms/reference.rb +21 -0
- data/lib/facturx/terms.rb +109 -0
- data/lib/facturx/version.rb +1 -1
- data/lib/facturx.rb +14 -0
- metadata +45 -4
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def reference(id, context: @document, base_xpath: nil, field: :id)
|
|
9
|
+
item = value(id, context:, base_xpath:)
|
|
10
|
+
DocumentReference.new(**{ field => item }) if item
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def identifier(value_id, scheme_id = nil, context: @document, base_xpath: nil)
|
|
14
|
+
return unless value_id
|
|
15
|
+
|
|
16
|
+
item = value(value_id, context:, base_xpath:)
|
|
17
|
+
unless item
|
|
18
|
+
@terms.mark_value(scheme_id, context:, base_xpath:) if scheme_id
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
scheme = value(scheme_id, context:, base_xpath:) if scheme_id
|
|
23
|
+
Identifier.new(value: item, scheme_id: scheme)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def quantity(value_id, unit_id, context:, base_xpath:)
|
|
27
|
+
amount = value(value_id, context:, base_xpath:)
|
|
28
|
+
unless amount
|
|
29
|
+
@terms.mark_value(unit_id, context:, base_xpath:)
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
unit = value(unit_id, context:, base_xpath:)
|
|
34
|
+
Quantity.new(value: amount, unit_code: unit)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tax_registration(attribute, scheme_id, node, base, group_id)
|
|
38
|
+
term = term_for(:party, group_id, attribute)
|
|
39
|
+
return unless term
|
|
40
|
+
|
|
41
|
+
nodes = node.xpath("./ram:SpecifiedTaxRegistration/ram:ID[@schemeID='#{scheme_id}']", NAMESPACES)
|
|
42
|
+
item = value(term.id, context: node, base_xpath: base, nodes:)
|
|
43
|
+
Identifier.new(value: item, scheme_id:) if item
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def read_price_allowance_indicator(node, base)
|
|
47
|
+
allowance = node.at_xpath('./ram:AppliedTradeAllowanceCharge', NAMESPACES)
|
|
48
|
+
return unless allowance
|
|
49
|
+
|
|
50
|
+
indicator = allowance.at_xpath('./ram:ChargeIndicator', NAMESPACES)
|
|
51
|
+
@terms.mark(indicator)
|
|
52
|
+
allowance_base = "#{base}/ram:AppliedTradeAllowanceCharge"
|
|
53
|
+
value('BT-147-02', context: allowance, base_xpath: allowance_base)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def technical_value(xpath, context)
|
|
57
|
+
@terms.raw_value(xpath, context:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def technical_boolean(xpath, context, term_id:, required: false, missing_path: xpath)
|
|
61
|
+
@terms.coerced_value(xpath, context:, type: :boolean, term_id:, required:, missing_path:)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def value(id, context: @document, base_group: nil, base_xpath: nil, nodes: nil)
|
|
65
|
+
return unless id
|
|
66
|
+
|
|
67
|
+
@terms.value(id, context:, base_xpath: base_xpath || (base_group && group_xpath(base_group)), nodes:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scalar_value(id, **)
|
|
71
|
+
Array(value(id, **)).first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def scalar_attributes(model, group_id, context: @document, base_xpath: nil, **selection)
|
|
75
|
+
only = selection[:only]
|
|
76
|
+
except = selection.fetch(:except, [])
|
|
77
|
+
model_terms(model, group_id)
|
|
78
|
+
.group_by(&:attribute)
|
|
79
|
+
.select { |attribute, terms| terms.one? && selected_attribute?(attribute, only, except) }
|
|
80
|
+
.to_h { |attribute, terms| [attribute, value(terms.first.id, context:, base_xpath:)] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def selected_attribute?(attribute, only, except)
|
|
84
|
+
(only.nil? || only.include?(attribute)) && !except.include?(attribute)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def model_terms(model, group_id)
|
|
88
|
+
@terms_by_model_and_group.fetch([model, group_id], EMPTY_TERMS)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def term_for(model, group_id, attribute, xpath_suffix: nil)
|
|
92
|
+
terms = model_terms(model, group_id).select { |term| term.attribute == attribute }
|
|
93
|
+
terms.find { |term| xpath_suffix.nil? || term.xpath.end_with?(xpath_suffix) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def term_for_attribute(model, attribute, xpath_suffix: nil)
|
|
97
|
+
terms = @terms_by_model_and_attribute.fetch([model, attribute], EMPTY_TERMS)
|
|
98
|
+
terms.find { |term| xpath_suffix.nil? || term.xpath.end_with?(xpath_suffix) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def group_nodes(id, **, &)
|
|
102
|
+
@terms.group_nodes(id, **, &)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def first_group(id, **)
|
|
106
|
+
group_nodes(id, **).first
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def group_xpath(id)
|
|
110
|
+
@registry.group(id).xpath
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def lines
|
|
9
|
+
group_nodes('BG-25').map { |node| line(node) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def line(node)
|
|
13
|
+
base = group_xpath('BG-25')
|
|
14
|
+
Line.new(
|
|
15
|
+
**scalar_attributes(:line, 'BG-25', context: node, base_xpath: base),
|
|
16
|
+
**line_trade_attributes(node, base),
|
|
17
|
+
**line_reference_attributes(node, base)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def line_trade_attributes(node, base)
|
|
22
|
+
{
|
|
23
|
+
product: product(node, base),
|
|
24
|
+
quantity: quantity('BT-129', 'BT-130', context: node, base_xpath: base),
|
|
25
|
+
gross_price: price(node, base, gross: true),
|
|
26
|
+
net_price: price(node, base, gross: false),
|
|
27
|
+
tax: line_tax(node, base),
|
|
28
|
+
period: period('BG-26', context: node, base_xpath: base),
|
|
29
|
+
allowances: allowance_charges('BG-27', charge: false, context: node, base_xpath: base),
|
|
30
|
+
charges: allowance_charges('BG-28', charge: true, context: node, base_xpath: base)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def line_reference_attributes(node, base)
|
|
35
|
+
{
|
|
36
|
+
buyer_order_reference: reference('BT-132', context: node, base_xpath: base, field: :line_id),
|
|
37
|
+
invoiced_object_identifier: identifier('BT-128', 'BT-128-1', context: node, base_xpath: base)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def product(parent, parent_base)
|
|
42
|
+
node = first_group('BG-31', context: parent, base_xpath: parent_base)
|
|
43
|
+
return unless node
|
|
44
|
+
|
|
45
|
+
base = group_xpath('BG-31')
|
|
46
|
+
Product.new(**product_attributes_hash(node, base))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def product_attributes_hash(node, base)
|
|
50
|
+
identifiers = %i[seller_identifier buyer_identifier global_identifier]
|
|
51
|
+
scalar_attributes(:product, 'BG-31', context: node, base_xpath: base, except: identifiers).merge(
|
|
52
|
+
seller_identifier: identifier('BT-155', context: node, base_xpath: base),
|
|
53
|
+
buyer_identifier: identifier('BT-156', context: node, base_xpath: base),
|
|
54
|
+
global_identifier: identifier('BT-157', 'BT-157-1', context: node, base_xpath: base),
|
|
55
|
+
attributes: product_attributes(node, base),
|
|
56
|
+
classifications: product_classifications(node, base)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def product_attributes(parent, parent_base)
|
|
61
|
+
group_nodes('BG-32', context: parent, base_xpath: parent_base).map do |node|
|
|
62
|
+
base = group_xpath('BG-32')
|
|
63
|
+
ProductAttribute.new(**scalar_attributes(:product_attribute, 'BG-32', context: node, base_xpath: base))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def product_classifications(parent, parent_base)
|
|
68
|
+
nodes = parent.xpath('./ram:DesignatedProductClassification', NAMESPACES)
|
|
69
|
+
nodes.map do |node|
|
|
70
|
+
base = "#{parent_base}/ram:DesignatedProductClassification"
|
|
71
|
+
ProductClassification.new(
|
|
72
|
+
code: scalar_value('BT-158', context: node, base_xpath: base),
|
|
73
|
+
list_id: value('BT-158-1', context: node, base_xpath: base),
|
|
74
|
+
list_version_id: value('BT-158-2', context: node, base_xpath: base)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def price(parent, parent_base, gross:)
|
|
80
|
+
prefix = gross ? 'Gross' : 'Net'
|
|
81
|
+
node = parent.at_xpath("./ram:SpecifiedLineTradeAgreement/ram:#{prefix}PriceProductTradePrice", NAMESPACES)
|
|
82
|
+
unless node
|
|
83
|
+
value('BT-146', context: parent, base_xpath: parent_base) unless gross
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
base = "#{parent_base}/ram:SpecifiedLineTradeAgreement/ram:#{prefix}PriceProductTradePrice"
|
|
88
|
+
Price.new(**price_attributes(node, base, gross))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def price_attributes(node, base, gross)
|
|
92
|
+
ids = gross ? %w[BT-148 BT-149-1 BT-150-1 BT-147] : %w[BT-146 BT-149 BT-150]
|
|
93
|
+
{
|
|
94
|
+
amount: value(ids[0], context: node, base_xpath: base),
|
|
95
|
+
basis_quantity: quantity(ids[1], ids[2], context: node, base_xpath: base),
|
|
96
|
+
discount: gross ? price_discount(node, base, ids[3]) : nil
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def price_discount(node, base, amount_id)
|
|
101
|
+
amount = value(amount_id, context: node, base_xpath: base)
|
|
102
|
+
indicator = read_price_allowance_indicator(node, base)
|
|
103
|
+
return amount if indicator == false
|
|
104
|
+
return unless indicator == true
|
|
105
|
+
|
|
106
|
+
@terms.add(:invalid_value, 'BT-147-02', base, 'Gross price discount cannot be a charge', value: indicator)
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def line_tax(parent, parent_base)
|
|
111
|
+
node = first_group('BG-30', context: parent, base_xpath: parent_base)
|
|
112
|
+
return unless node
|
|
113
|
+
|
|
114
|
+
base = group_xpath('BG-30')
|
|
115
|
+
attributes = scalar_attributes(:tax_breakdown, 'BG-30', context: node, base_xpath: base)
|
|
116
|
+
TaxBreakdown.new(type_code: technical_value('./ram:TypeCode', node), **attributes)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def party(group_id, contact_group: nil, address_group: nil)
|
|
9
|
+
node = first_group(group_id)
|
|
10
|
+
return unless node
|
|
11
|
+
|
|
12
|
+
base = group_xpath(group_id)
|
|
13
|
+
Party.new(**party_scalar_attributes(node, base, group_id),
|
|
14
|
+
**party_identifier_attributes(node, base, group_id),
|
|
15
|
+
**party_group_attributes(node, base, contact_group, address_group))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def party_scalar_attributes(node, base, group_id)
|
|
19
|
+
scalar_attributes(:party, group_id, context: node, base_xpath: base,
|
|
20
|
+
only: %i[name description trading_name])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def party_identifier_attributes(node, base, group_id)
|
|
24
|
+
{
|
|
25
|
+
identifiers: party_identifiers(node, base, group_id),
|
|
26
|
+
legal_registration: compound_identifier(:legal_registration, node, base, group_id),
|
|
27
|
+
vat_identifier: tax_registration(:vat_identifier, 'VA', node, base, group_id),
|
|
28
|
+
tax_identifier: tax_registration(:tax_identifier, 'FC', node, base, group_id),
|
|
29
|
+
electronic_address: compound_identifier(:electronic_address, node, base, group_id)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def party_group_attributes(node, base, contact_group, address_group)
|
|
34
|
+
{
|
|
35
|
+
address: address_group && address(node, base, address_group),
|
|
36
|
+
contact: contact_group && contact(node, base, contact_group)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def party_identifiers(node, base, group_id)
|
|
41
|
+
value_term = term_for(:party, group_id, :identifiers, xpath_suffix: '/ram:ID')
|
|
42
|
+
return [] unless value_term
|
|
43
|
+
|
|
44
|
+
identifiers = local_party_identifiers(value_term, node, base)
|
|
45
|
+
scheme_term = term_for(:party, group_id, :identifiers, xpath_suffix: '/@schemeID')
|
|
46
|
+
return identifiers unless scheme_term
|
|
47
|
+
|
|
48
|
+
identifiers.concat(global_party_identifiers(node))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def local_party_identifiers(term, node, base)
|
|
52
|
+
Array(value(term.id, context: node, base_xpath: base)).compact.map { |item| Identifier.new(value: item) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def global_party_identifiers(node)
|
|
56
|
+
node.xpath('./ram:GlobalID', NAMESPACES).map do |global_id|
|
|
57
|
+
@terms.mark(global_id)
|
|
58
|
+
Identifier.new(value: global_id.text, scheme_id: global_id['schemeID'])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def compound_identifier(attribute, node, base, group_id)
|
|
63
|
+
value_term = term_for(:party, group_id, attribute, xpath_suffix: '/ram:ID') ||
|
|
64
|
+
term_for(:party, group_id, attribute, xpath_suffix: '/ram:URIID')
|
|
65
|
+
return unless value_term
|
|
66
|
+
|
|
67
|
+
scheme_term = term_for(:party, group_id, attribute, xpath_suffix: '/@schemeID')
|
|
68
|
+
identifier(value_term.id, scheme_term&.id, context: node, base_xpath: base)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def contact(parent, parent_base, group_id)
|
|
72
|
+
node = first_group(group_id, context: parent, base_xpath: parent_base)
|
|
73
|
+
return unless node
|
|
74
|
+
|
|
75
|
+
base = group_xpath(group_id)
|
|
76
|
+
attributes = scalar_attributes(:contact, group_id, context: node, base_xpath: base)
|
|
77
|
+
Contact.new(**attributes)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def address(parent, parent_base, group_id)
|
|
81
|
+
node = first_group(group_id, context: parent, base_xpath: parent_base)
|
|
82
|
+
return unless node
|
|
83
|
+
|
|
84
|
+
base = group_xpath(group_id)
|
|
85
|
+
attributes = scalar_attributes(:address, group_id, context: node, base_xpath: base)
|
|
86
|
+
Address.new(**attributes)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def payment
|
|
9
|
+
nodes = group_nodes('BG-16')
|
|
10
|
+
base = group_xpath('BG-16')
|
|
11
|
+
attributes = settlement_payment_attributes(nodes.first, base)
|
|
12
|
+
attributes.merge!(payment_means_attributes(nodes.first, base)) if nodes.first
|
|
13
|
+
return if attributes.empty?
|
|
14
|
+
|
|
15
|
+
PaymentInstructions.new(**attributes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def settlement_payment_attributes(payment_means, base)
|
|
19
|
+
attributes = scalar_attributes(:payment_instructions, 'BG-19')
|
|
20
|
+
direct_debit_details = direct_debit(payment_means, base)
|
|
21
|
+
attributes[:direct_debit] = direct_debit_details if direct_debit_details
|
|
22
|
+
attributes.compact
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def payment_means_attributes(node, base)
|
|
26
|
+
provider = term_for(:credit_transfer, 'BG-16', :provider_identifier)
|
|
27
|
+
provider_identifier = identifier(provider&.id, context: node, base_xpath: base)
|
|
28
|
+
scalar_attributes(:payment_instructions, 'BG-16', context: node, base_xpath: base).merge(
|
|
29
|
+
credit_transfers: credit_transfers(node, base, provider_identifier),
|
|
30
|
+
payment_card: payment_card(node, base)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def credit_transfers(parent, parent_base, provider_identifier)
|
|
35
|
+
group_nodes('BG-17', context: parent, base_xpath: parent_base).map do |node|
|
|
36
|
+
credit_transfer(node, provider_identifier)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def credit_transfer(node, provider_identifier)
|
|
41
|
+
base = group_xpath('BG-17')
|
|
42
|
+
account = term_for(:credit_transfer, 'BG-17', :account_identifier)
|
|
43
|
+
attributes = scalar_attributes(:credit_transfer, 'BG-17', context: node, base_xpath: base,
|
|
44
|
+
except: [:account_identifier])
|
|
45
|
+
CreditTransfer.new(**attributes, account_identifier: identifier(account.id, context: node, base_xpath: base),
|
|
46
|
+
provider_identifier:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def payment_card(parent, parent_base)
|
|
50
|
+
node = first_group('BG-18', context: parent, base_xpath: parent_base)
|
|
51
|
+
return unless node
|
|
52
|
+
|
|
53
|
+
base = group_xpath('BG-18')
|
|
54
|
+
PaymentCard.new(**scalar_attributes(:payment_card, 'BG-18', context: node, base_xpath: base))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def direct_debit(payment_means, base)
|
|
58
|
+
values = %i[mandate_identifier creditor_identifier].to_h do |attribute|
|
|
59
|
+
[attribute, identifier(term_for_attribute(:direct_debit, attribute)&.id)]
|
|
60
|
+
end
|
|
61
|
+
if payment_means
|
|
62
|
+
values[:debtor_account_identifier] = identifier(
|
|
63
|
+
term_for(:direct_debit, 'BG-16', :debtor_account_identifier)&.id,
|
|
64
|
+
context: payment_means, base_xpath: base
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
DirectDebit.new(**values) if values.values.any?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def read_tax_breakdowns
|
|
9
|
+
nodes = group_nodes('BG-23')
|
|
10
|
+
base = group_xpath('BG-23')
|
|
11
|
+
vat_point_date = nil
|
|
12
|
+
items = nodes.map do |node|
|
|
13
|
+
tax_point_date = value('BT-7', context: node, base_xpath: base)
|
|
14
|
+
vat_point_date ||= tax_point_date
|
|
15
|
+
attributes = scalar_attributes(:tax_breakdown, 'BG-23', context: node, base_xpath: base)
|
|
16
|
+
TaxBreakdown.new(type_code: technical_value('./ram:TypeCode', node), **attributes)
|
|
17
|
+
end
|
|
18
|
+
TaxBreakdownResult.new(items:, vat_point_date:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def allowance_charges(group_id, charge:, context: @document, base_xpath: nil)
|
|
22
|
+
group_nodes(group_id, context:, base_xpath:) { |node| charge_indicator(node, group_id) == charge }.map do |node|
|
|
23
|
+
base = group_xpath(group_id)
|
|
24
|
+
AllowanceCharge.new(**allowance_attributes(node, base, group_id), indicator: charge,
|
|
25
|
+
tax: allowance_tax(node, base, group_id))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def allowance_attributes(node, base, group_id)
|
|
30
|
+
scalar_attributes(:allowance_charge, group_id, context: node, base_xpath: base, except: [:tax])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def allowance_tax(node, base, group_id)
|
|
34
|
+
technical_value('./ram:CategoryTradeTax/ram:TypeCode', node)
|
|
35
|
+
category = value(term_for(:allowance_charge, group_id, :tax,
|
|
36
|
+
xpath_suffix: '/ram:CategoryTradeTax/ram:CategoryCode')&.id,
|
|
37
|
+
context: node, base_xpath: base)
|
|
38
|
+
rate = value(term_for(:allowance_charge, group_id, :tax,
|
|
39
|
+
xpath_suffix: '/ram:CategoryTradeTax/ram:RateApplicablePercent')&.id,
|
|
40
|
+
context: node, base_xpath: base)
|
|
41
|
+
TaxBreakdown.new(category_code: category, rate:) if category || rate
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def charge_indicator(node, group_id)
|
|
45
|
+
@charge_indicators ||= {}.compare_by_identity
|
|
46
|
+
@charge_indicators.fetch(node) do
|
|
47
|
+
path = './ram:ChargeIndicator/udt:Indicator'
|
|
48
|
+
@charge_indicators[node] = technical_boolean(
|
|
49
|
+
path, node, term_id: "#{group_id}-1", required: true, missing_path: group_xpath("#{group_id}-1")
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def period(group_id, context: @document, base_xpath: nil)
|
|
55
|
+
node = first_group(group_id, context:, base_xpath:)
|
|
56
|
+
return unless node
|
|
57
|
+
|
|
58
|
+
base = group_xpath(group_id)
|
|
59
|
+
Period.new(**scalar_attributes(:period, group_id, context: node, base_xpath: base))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def totals(currency, tax_currency)
|
|
63
|
+
node = first_group('BG-22')
|
|
64
|
+
return unless node
|
|
65
|
+
|
|
66
|
+
base = group_xpath('BG-22')
|
|
67
|
+
attributes = scalar_attributes(:totals, 'BG-22', context: node, base_xpath: base,
|
|
68
|
+
except: %i[tax_total tax_total_in_tax_currency])
|
|
69
|
+
Totals.new(**attributes, **tax_total_attributes(node, base, currency, tax_currency))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tax_total_attributes(node, base, currency, tax_currency)
|
|
73
|
+
total_nodes = node.xpath('./ram:TaxTotalAmount', NAMESPACES)
|
|
74
|
+
invoice_tax = total_nodes.select { |item| tax_currency.nil? || item['currencyID'] == currency }
|
|
75
|
+
accounting_tax = total_nodes.select { |item| tax_currency && item['currencyID'] == tax_currency }
|
|
76
|
+
{
|
|
77
|
+
tax_total: value('BT-110', context: node, base_xpath: base, nodes: invoice_tax),
|
|
78
|
+
tax_total_in_tax_currency: value('BT-111', context: node, base_xpath: base, nodes: accounting_tax)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
EMPTY_TERMS = [].freeze
|
|
7
|
+
TaxBreakdownResult = Data.define(:items, :vat_point_date)
|
|
8
|
+
|
|
9
|
+
def initialize(document:, profile:, registry:, coercer:, diagnostics:)
|
|
10
|
+
@document = document
|
|
11
|
+
@profile = profile
|
|
12
|
+
@terms = TermReader.new(document:, profile:, registry:, coercer:, diagnostics:)
|
|
13
|
+
@registry = registry
|
|
14
|
+
active_terms = registry.for_profile(profile)
|
|
15
|
+
@terms_by_model_and_group = active_terms.group_by { |term| [term.model, term.group_id] }.freeze
|
|
16
|
+
@terms_by_model_and_attribute = active_terms.group_by { |term| [term.model, term.attribute] }.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
tax_breakdowns = read_tax_breakdowns
|
|
21
|
+
attributes = document_attributes(tax_breakdowns).merge(
|
|
22
|
+
party_attributes, transaction_attributes, collection_attributes(tax_breakdowns.items)
|
|
23
|
+
)
|
|
24
|
+
attributes[:totals] = totals(attributes[:currency], attributes[:tax_currency])
|
|
25
|
+
Document.new(**attributes)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add_unmapped(root)
|
|
29
|
+
@terms.add_unmapped(root)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def party_attributes
|
|
35
|
+
{
|
|
36
|
+
seller: party('BG-4', contact_group: 'BG-6', address_group: 'BG-5'),
|
|
37
|
+
buyer: party('BG-7', contact_group: 'BG-9', address_group: 'BG-8'),
|
|
38
|
+
payee: party('BG-10'),
|
|
39
|
+
tax_representative: party('BG-11', address_group: 'BG-12')
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def transaction_attributes
|
|
44
|
+
{
|
|
45
|
+
delivery: delivery, billing_period: period('BG-14'), payment: payment
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def collection_attributes(tax_breakdowns)
|
|
50
|
+
{
|
|
51
|
+
notes: notes, lines: lines, tax_breakdowns:,
|
|
52
|
+
allowances: allowance_charges('BG-20', charge: false),
|
|
53
|
+
charges: allowance_charges('BG-21', charge: true),
|
|
54
|
+
preceding_invoices: preceding_invoices, supporting_documents: supporting_documents
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
require_relative 'semantic_mapper/document_mapping'
|
|
62
|
+
require_relative 'semantic_mapper/party_mapping'
|
|
63
|
+
require_relative 'semantic_mapper/delivery_mapping'
|
|
64
|
+
require_relative 'semantic_mapper/payment_mapping'
|
|
65
|
+
require_relative 'semantic_mapper/line_mapping'
|
|
66
|
+
require_relative 'semantic_mapper/trade_mapping'
|
|
67
|
+
require_relative 'semantic_mapper/helpers'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class TermReader
|
|
6
|
+
def coerced_value(xpath, context:, type:, term_id: nil, **options)
|
|
7
|
+
node = context.at_xpath(xpath, NAMESPACES)
|
|
8
|
+
return diagnose_raw_missing(options.fetch(:missing_path, xpath), term_id) if node.nil? && options[:required]
|
|
9
|
+
return unless node
|
|
10
|
+
|
|
11
|
+
coerce_raw_value(node, type:, term_id:)
|
|
12
|
+
rescue CoercionError => e
|
|
13
|
+
add(:invalid_value, term_id, node.path, e.message, e.details)
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def coerce_raw_value(node, type:, term_id:)
|
|
20
|
+
mark(node)
|
|
21
|
+
return diagnose_raw_empty(node, term_id) if node.text.strip.empty?
|
|
22
|
+
|
|
23
|
+
@coercer.call(node.text, type:, term_id:, path: node.path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def coerce_term_value(node, term)
|
|
27
|
+
@coercer.call(node.text, type: term.type, scale: term.scale, term_id: term.id, path: node.path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def coerce(node, term)
|
|
31
|
+
return diagnose_date_format(node, term) if invalid_date_format?(node, term)
|
|
32
|
+
return diagnose_empty(node, term) if node.text.strip.empty?
|
|
33
|
+
|
|
34
|
+
coerce_term_value(node, term)
|
|
35
|
+
rescue CoercionError => e
|
|
36
|
+
add(:invalid_value, term.id, node.path, e.message, e.details)
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def diagnose_empty(node, term)
|
|
41
|
+
add(:empty_value, term.id, node.path, 'Value is empty', value: node.text)
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def diagnose_raw_empty(node, term_id)
|
|
46
|
+
add(:empty_value, term_id, node.path, 'Value is empty', value: node.text)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def diagnose_raw_missing(path, term_id)
|
|
51
|
+
add(:missing_required_term, term_id, path, 'Required value is missing')
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def invalid_date_format?(node, term)
|
|
56
|
+
return false unless term.type == :date_102
|
|
57
|
+
|
|
58
|
+
format = node.attribute('format')
|
|
59
|
+
mark(format)
|
|
60
|
+
format&.value != '102'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def diagnose_date_format(node, term)
|
|
64
|
+
add(:invalid_value, term.id, node.path, 'Date format must be 102', format: node['format'])
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class TermReader
|
|
6
|
+
IGNORED_ATTRIBUTES = %w[currencyID schemeID].freeze
|
|
7
|
+
|
|
8
|
+
def add_unmapped(root)
|
|
9
|
+
add_unmapped_elements(root)
|
|
10
|
+
add_unmapped_attributes(root)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def add_unmapped_elements(root)
|
|
16
|
+
root.xpath('.//*[not(*)]').each do |node|
|
|
17
|
+
next if node.text.empty? || matched?(node)
|
|
18
|
+
|
|
19
|
+
add(:unmapped_element, nil, node.path, 'Element is not mapped to the EN16931 model', value: node.text)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add_unmapped_attributes(root)
|
|
24
|
+
root.xpath('.//@*').each do |attribute|
|
|
25
|
+
next if IGNORED_ATTRIBUTES.include?(attribute.name) || matched?(attribute)
|
|
26
|
+
|
|
27
|
+
add(:unmapped_element, nil, attribute.path, 'Attribute is not mapped to the EN16931 model',
|
|
28
|
+
value: attribute.value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def matched?(node)
|
|
33
|
+
@matched_nodes.key?(node)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|