invoice_extractor_arca 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 +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE.txt +21 -0
- data/README.md +631 -0
- data/exe/arca-invoice-extract +6 -0
- data/lib/invoice_extractor_arca/catalogs/copy_types.json +5 -0
- data/lib/invoice_extractor_arca/catalogs/currencies.json +65 -0
- data/lib/invoice_extractor_arca/catalogs/document_types.json +40 -0
- data/lib/invoice_extractor_arca/catalogs/iva_types.json +13 -0
- data/lib/invoice_extractor_arca/catalogs/payment_methods.json +10 -0
- data/lib/invoice_extractor_arca/catalogs/units_measure.json +47 -0
- data/lib/invoice_extractor_arca/catalogs/voucher_types.json +99 -0
- data/lib/invoice_extractor_arca/catalogs.rb +34 -0
- data/lib/invoice_extractor_arca/cli.rb +247 -0
- data/lib/invoice_extractor_arca/command_runner.rb +18 -0
- data/lib/invoice_extractor_arca/configuration.rb +42 -0
- data/lib/invoice_extractor_arca/dependencies/resolver.rb +118 -0
- data/lib/invoice_extractor_arca/error.rb +6 -0
- data/lib/invoice_extractor_arca/error_record.rb +39 -0
- data/lib/invoice_extractor_arca/errors.rb +34 -0
- data/lib/invoice_extractor_arca/extractor.rb +381 -0
- data/lib/invoice_extractor_arca/final_payload_builder.rb +67 -0
- data/lib/invoice_extractor_arca/ocr/rapidocr_extract.py +198 -0
- data/lib/invoice_extractor_arca/ocr/rapidocr_text_extractor.rb +117 -0
- data/lib/invoice_extractor_arca/pdf/ocr_text_extractor.rb +131 -0
- data/lib/invoice_extractor_arca/pdf/renderer.rb +97 -0
- data/lib/invoice_extractor_arca/pdf/text_extractor.rb +182 -0
- data/lib/invoice_extractor_arca/qr/payload_parser.rb +124 -0
- data/lib/invoice_extractor_arca/qr/reader.rb +53 -0
- data/lib/invoice_extractor_arca/qr/result_builder.rb +229 -0
- data/lib/invoice_extractor_arca/qr_extractor.rb +156 -0
- data/lib/invoice_extractor_arca/result.rb +94 -0
- data/lib/invoice_extractor_arca/scanners/factory.rb +88 -0
- data/lib/invoice_extractor_arca/scanners/qreader_scan.py +102 -0
- data/lib/invoice_extractor_arca/scanners/qreader_scanner.rb +131 -0
- data/lib/invoice_extractor_arca/scanners/scanner_chain.rb +73 -0
- data/lib/invoice_extractor_arca/scanners/zbar_scanner.rb +52 -0
- data/lib/invoice_extractor_arca/text/invoice_parser.rb +517 -0
- data/lib/invoice_extractor_arca/text/layout_table_parser.rb +218 -0
- data/lib/invoice_extractor_arca/text/locale_value_normalizer.rb +53 -0
- data/lib/invoice_extractor_arca/text/normalizer.rb +31 -0
- data/lib/invoice_extractor_arca/validation/reconciler.rb +53 -0
- data/lib/invoice_extractor_arca/version.rb +5 -0
- data/lib/invoice_extractor_arca/voucher_validator.rb +169 -0
- data/lib/invoice_extractor_arca.rb +117 -0
- data/requirements-ocr.txt +2 -0
- data/sig/invoice_extractor_arca.rbs +4 -0
- metadata +135 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error_record"
|
|
4
|
+
|
|
5
|
+
module InvoiceExtractorArca
|
|
6
|
+
module Text
|
|
7
|
+
class LayoutTableParser
|
|
8
|
+
HEADERS = [
|
|
9
|
+
[:code, /C[oó]digo/i],
|
|
10
|
+
[:description, /Producto\s*\/\s*Servicio/i],
|
|
11
|
+
[:quantity, /Cantidad/i],
|
|
12
|
+
[:unit_measure, /U\.\s*Medida/i],
|
|
13
|
+
[:unit_price, /Precio\s+Unit\./i],
|
|
14
|
+
[:discount_percentage, /%\s*Bonif\.?/i],
|
|
15
|
+
[:discount_total, /Imp\.\s*Bonif\.?/i],
|
|
16
|
+
[:subtotal, /Subtotal/i]
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
NUMERIC_FIELDS = %i[
|
|
20
|
+
quantity
|
|
21
|
+
unit_price
|
|
22
|
+
discount_percentage
|
|
23
|
+
discount_total
|
|
24
|
+
subtotal
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
NUMERIC_PATTERN =
|
|
28
|
+
/\A-?\d+(?:\.\d{3})*(?:,\d+)?\z/
|
|
29
|
+
|
|
30
|
+
def initialize(value_normalizer:)
|
|
31
|
+
@value_normalizer = value_normalizer
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse(raw)
|
|
35
|
+
lines = raw.to_s.lines.map(&:chomp)
|
|
36
|
+
header_index, positions = find_header(lines)
|
|
37
|
+
|
|
38
|
+
unless header_index
|
|
39
|
+
return failure(
|
|
40
|
+
"enrichment_service_table_not_found",
|
|
41
|
+
"Service table header was not found"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
body = table_body(lines, header_index)
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
rows: build_rows(body, positions),
|
|
49
|
+
diagnostics: []
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def find_header(lines)
|
|
56
|
+
lines.each_with_index do |line, index|
|
|
57
|
+
positions = header_positions(line)
|
|
58
|
+
return [index, positions] if positions
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
[nil, nil]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def header_positions(line)
|
|
65
|
+
positions = {}
|
|
66
|
+
|
|
67
|
+
HEADERS.each do |name, pattern|
|
|
68
|
+
match = line.match(pattern)
|
|
69
|
+
return nil unless match
|
|
70
|
+
|
|
71
|
+
positions[name] = match.begin(0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
offsets = positions.values
|
|
75
|
+
return nil unless offsets == offsets.sort
|
|
76
|
+
return nil unless offsets.uniq.length == offsets.length
|
|
77
|
+
|
|
78
|
+
positions
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def table_body(lines, header_index)
|
|
82
|
+
lines[(header_index + 1)..].to_a.take_while do |line|
|
|
83
|
+
!line.match?(/^\s*Subtotal\s*:\s*\$/i) &&
|
|
84
|
+
!line.match?(/Importe\s+Otros\s+Tributos\s*:\s*\$/i) &&
|
|
85
|
+
!line.match?(/Importe\s+Total\s*:\s*\$/i)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_rows(lines, positions)
|
|
90
|
+
rows = []
|
|
91
|
+
current = nil
|
|
92
|
+
|
|
93
|
+
lines.each do |line|
|
|
94
|
+
cells = slice_line(line, positions)
|
|
95
|
+
|
|
96
|
+
if row_start?(cells)
|
|
97
|
+
rows << finalize_row(current) if current
|
|
98
|
+
current = cells
|
|
99
|
+
elsif current && continuation_line?(cells)
|
|
100
|
+
append_description(current, cells[:description])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
rows << finalize_row(current) if current
|
|
105
|
+
rows
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def slice_line(line, positions)
|
|
109
|
+
names = positions.keys
|
|
110
|
+
|
|
111
|
+
cells =
|
|
112
|
+
names.each_with_index.each_with_object({}) do |(name, index), result|
|
|
113
|
+
from = positions.fetch(name)
|
|
114
|
+
next_name = names[index + 1]
|
|
115
|
+
to = next_name ? positions.fetch(next_name) : nil
|
|
116
|
+
|
|
117
|
+
result[name] = line[from...to].to_s.rstrip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
repair_description_overlap(cells)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def row_start?(cells)
|
|
124
|
+
required = %i[
|
|
125
|
+
quantity
|
|
126
|
+
unit_price
|
|
127
|
+
discount_percentage
|
|
128
|
+
discount_total
|
|
129
|
+
subtotal
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
required.all? do |name|
|
|
133
|
+
numeric?(cells.fetch(name))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def continuation_line?(cells)
|
|
138
|
+
description_present = !cells[:description].to_s.strip.empty?
|
|
139
|
+
|
|
140
|
+
numeric_cells_empty =
|
|
141
|
+
NUMERIC_FIELDS.all? do |name|
|
|
142
|
+
cells.fetch(name).to_s.strip.empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
description_present && numeric_cells_empty
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def append_description(row, continuation)
|
|
149
|
+
text = continuation.to_s.strip
|
|
150
|
+
return if text.empty?
|
|
151
|
+
|
|
152
|
+
row[:description] =
|
|
153
|
+
[row[:description].to_s.strip, text]
|
|
154
|
+
.reject(&:empty?)
|
|
155
|
+
.join(" ")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def finalize_row(cells)
|
|
159
|
+
cells.each_with_object({}) do |(name, raw), row|
|
|
160
|
+
cleaned = raw.to_s.strip
|
|
161
|
+
normalized = normalize_cell(name, cleaned)
|
|
162
|
+
|
|
163
|
+
row[name] = {
|
|
164
|
+
raw: cleaned,
|
|
165
|
+
normalized: normalized,
|
|
166
|
+
source: "pdf_text",
|
|
167
|
+
method: "layout_column",
|
|
168
|
+
confidence: confidence_for(name, cleaned, normalized)
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def normalize_cell(name, value)
|
|
174
|
+
return @value_normalizer.decimal(value) if NUMERIC_FIELDS.include?(name)
|
|
175
|
+
|
|
176
|
+
value
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def confidence_for(name, raw, normalized)
|
|
180
|
+
return "high" unless NUMERIC_FIELDS.include?(name)
|
|
181
|
+
return "low" if raw.empty? || normalized.nil?
|
|
182
|
+
|
|
183
|
+
"high"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def numeric?(value)
|
|
187
|
+
value.to_s.strip.match?(NUMERIC_PATTERN)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def failure(code, message)
|
|
191
|
+
{
|
|
192
|
+
rows: [],
|
|
193
|
+
diagnostics: [
|
|
194
|
+
ErrorRecord.new(
|
|
195
|
+
code: code,
|
|
196
|
+
message: message,
|
|
197
|
+
severity: "warning",
|
|
198
|
+
component: "enrichment"
|
|
199
|
+
)
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def repair_description_overlap(cells)
|
|
205
|
+
code = cells[:code].to_s.strip
|
|
206
|
+
description = cells[:description].to_s
|
|
207
|
+
|
|
208
|
+
if code.match?(/\A[[:alpha:]]\z/) &&
|
|
209
|
+
description.match?(/\A[[:lower:]]/)
|
|
210
|
+
cells[:description] = "#{code}#{description}"
|
|
211
|
+
cells[:code] = ""
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
cells
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# lib/invoice_extractor_arca/text/locale_value_normalizer.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module InvoiceExtractorArca
|
|
8
|
+
module Text
|
|
9
|
+
class LocaleValueNormalizer
|
|
10
|
+
def date(value)
|
|
11
|
+
Date.strptime(value.to_s.strip, "%d/%m/%Y").iso8601
|
|
12
|
+
rescue Date::Error
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def digits(value)
|
|
17
|
+
result = value.to_s.gsub(/\D/, "")
|
|
18
|
+
result.empty? ? nil : result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def code(value, width)
|
|
22
|
+
result = digits(value)
|
|
23
|
+
return nil unless result && result.length <= width
|
|
24
|
+
|
|
25
|
+
result.rjust(width, "0")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def decimal(value)
|
|
29
|
+
input = value.to_s.strip.delete(" ")
|
|
30
|
+
return nil unless input
|
|
31
|
+
|
|
32
|
+
normalized =
|
|
33
|
+
if input.include?(",")
|
|
34
|
+
input.delete(".").sub(",", ".")
|
|
35
|
+
else
|
|
36
|
+
input
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
return nil unless normalized.match?(/\A-?\d+(?:\.\d+)?\z/)
|
|
40
|
+
|
|
41
|
+
format_decimal(BigDecimal(normalized), 2)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def format_decimal(value, scale)
|
|
45
|
+
string = value.round(scale).to_s("F")
|
|
46
|
+
whole, fraction = string.split(".", 2)
|
|
47
|
+
fraction = fraction.to_s.ljust(scale, "0")[0, scale]
|
|
48
|
+
|
|
49
|
+
"#{whole}.#{fraction}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InvoiceExtractorArca
|
|
4
|
+
module Text
|
|
5
|
+
class Normalizer
|
|
6
|
+
def normalize(text)
|
|
7
|
+
normalized =
|
|
8
|
+
text
|
|
9
|
+
.to_s
|
|
10
|
+
.encode(
|
|
11
|
+
"UTF-8",
|
|
12
|
+
invalid: :replace,
|
|
13
|
+
undef: :replace,
|
|
14
|
+
replace: ""
|
|
15
|
+
)
|
|
16
|
+
.delete("\u0000")
|
|
17
|
+
.gsub(/\r\n?/, "\n")
|
|
18
|
+
.gsub("\f", "\n\n")
|
|
19
|
+
|
|
20
|
+
normalized =
|
|
21
|
+
normalized
|
|
22
|
+
.lines
|
|
23
|
+
.map(&:rstrip)
|
|
24
|
+
.join("\n")
|
|
25
|
+
.gsub(/\n{3,}/, "\n\n")
|
|
26
|
+
|
|
27
|
+
normalized.strip
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# lib/invoice_extractor_arca/validation/reconciler.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module InvoiceExtractorArca
|
|
5
|
+
module Validation
|
|
6
|
+
class Reconciler
|
|
7
|
+
MAPPINGS = {
|
|
8
|
+
voucher_type_code: :voucher_type_code,
|
|
9
|
+
selling_point: :point_of_sale,
|
|
10
|
+
invoice_number: :voucher_number,
|
|
11
|
+
emission_date: :issued_on,
|
|
12
|
+
biller_cuit: :issuer_cuit,
|
|
13
|
+
recipient_document_type: :recipient_document_type_description,
|
|
14
|
+
recipient_document_number: :recipient_document_number,
|
|
15
|
+
total: :amount,
|
|
16
|
+
authorization_code: :authorization_code
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def call(voucher:, enrichment:, parsing_warnings: [])
|
|
20
|
+
comparisons = MAPPINGS.map do |text_field, voucher_field|
|
|
21
|
+
text_value = enrichment.dig(text_field, :normalized)
|
|
22
|
+
qr_value = voucher[voucher_field]
|
|
23
|
+
|
|
24
|
+
status =
|
|
25
|
+
if text_value.nil?
|
|
26
|
+
"not_extracted"
|
|
27
|
+
elsif text_value == qr_value
|
|
28
|
+
"match"
|
|
29
|
+
else
|
|
30
|
+
"mismatch"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
field: text_field.to_s,
|
|
35
|
+
status: status,
|
|
36
|
+
qr_value: qr_value,
|
|
37
|
+
text_value: text_value,
|
|
38
|
+
severity: (status == "mismatch") ? "warning" : "info"
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
warning =
|
|
43
|
+
parsing_warnings.any? ||
|
|
44
|
+
comparisons.any? { |comparison| comparison[:status] == "mismatch" }
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
status: warning ? "warning" : "ok",
|
|
48
|
+
comparisons: comparisons
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require_relative "catalogs"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module InvoiceExtractorArca
|
|
9
|
+
class VoucherValidator
|
|
10
|
+
REQUIRED_FIELDS = %w[
|
|
11
|
+
ver
|
|
12
|
+
fecha
|
|
13
|
+
cuit
|
|
14
|
+
ptoVta
|
|
15
|
+
tipoCmp
|
|
16
|
+
nroCmp
|
|
17
|
+
importe
|
|
18
|
+
moneda
|
|
19
|
+
ctz
|
|
20
|
+
tipoDocRec
|
|
21
|
+
nroDocRec
|
|
22
|
+
tipoCodAut
|
|
23
|
+
codAut
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
def validate_and_normalize(original)
|
|
27
|
+
validate_hash!(original)
|
|
28
|
+
validate_required_fields!(original)
|
|
29
|
+
|
|
30
|
+
issued_on = normalize_date(original["fecha"])
|
|
31
|
+
issuer_cuit = digits(original["cuit"], expected: 11)
|
|
32
|
+
point_of_sale = normalize_code(original["ptoVta"], 5)
|
|
33
|
+
voucher_type_code = normalize_code(original["tipoCmp"], 3)
|
|
34
|
+
voucher_number = normalize_code(original["nroCmp"], 8)
|
|
35
|
+
currency_code = original["moneda"].to_s
|
|
36
|
+
recipient_document_type_code = original["tipoDocRec"].to_s
|
|
37
|
+
recipient_document_number = digits(original["nroDocRec"], max: 20)
|
|
38
|
+
authorization_code = digits(original["codAut"], expected: 14)
|
|
39
|
+
|
|
40
|
+
voucher_type_description =
|
|
41
|
+
catalog_value!(
|
|
42
|
+
Catalogs::VOUCHER_TYPES,
|
|
43
|
+
voucher_type_code,
|
|
44
|
+
"tipoCmp"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
currency_description =
|
|
48
|
+
catalog_value!(
|
|
49
|
+
Catalogs::CURRENCIES,
|
|
50
|
+
currency_code,
|
|
51
|
+
"moneda"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
recipient_document_type_description =
|
|
55
|
+
catalog_value!(
|
|
56
|
+
Catalogs::DOCUMENT_TYPES,
|
|
57
|
+
recipient_document_type_code,
|
|
58
|
+
"tipoDocRec"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
version: normalize_version(original["ver"]),
|
|
63
|
+
issued_on: issued_on,
|
|
64
|
+
issuer_cuit: issuer_cuit,
|
|
65
|
+
point_of_sale: point_of_sale,
|
|
66
|
+
voucher_type_code: voucher_type_code,
|
|
67
|
+
voucher_type_description: voucher_type_description,
|
|
68
|
+
voucher_number: voucher_number,
|
|
69
|
+
invoice_number: "#{point_of_sale}-#{voucher_number}",
|
|
70
|
+
amount: decimal(original["importe"], scale: 2, integer_digits: 13),
|
|
71
|
+
currency_code: currency_code,
|
|
72
|
+
currency_description: currency_description,
|
|
73
|
+
exchange_rate: decimal(original["ctz"], scale: 6, integer_digits: 13),
|
|
74
|
+
recipient_document_type_code: recipient_document_type_code,
|
|
75
|
+
recipient_document_type_description: recipient_document_type_description,
|
|
76
|
+
recipient_document_number: recipient_document_number,
|
|
77
|
+
authorization_type: original["tipoCodAut"].to_s,
|
|
78
|
+
authorization_code: authorization_code
|
|
79
|
+
}
|
|
80
|
+
rescue ArgumentError => e
|
|
81
|
+
raise Errors::InvalidVoucherSchema, e.message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_hash!(original)
|
|
87
|
+
return if original.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "Voucher payload must be a JSON object"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_required_fields!(original)
|
|
93
|
+
missing = REQUIRED_FIELDS.reject { |field| original.key?(field) }
|
|
94
|
+
return if missing.empty?
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "Missing fields: #{missing.join(", ")}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_version(value)
|
|
100
|
+
string = Integer(value).to_s
|
|
101
|
+
raise ArgumentError, "Invalid version" unless string.match?(/\A\d\z/)
|
|
102
|
+
|
|
103
|
+
Integer(string)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_date(value)
|
|
107
|
+
Date.iso8601(value.to_s).iso8601
|
|
108
|
+
rescue Date::Error
|
|
109
|
+
raise ArgumentError, "Invalid issue date"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize_code(value, width)
|
|
113
|
+
string = Integer(value).to_s
|
|
114
|
+
raise ArgumentError, "Invalid code length" if string.length > width
|
|
115
|
+
|
|
116
|
+
string.rjust(width, "0")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def digits(value, expected: nil, max: nil)
|
|
120
|
+
string = value.to_s.gsub(/\D/, "")
|
|
121
|
+
raise ArgumentError, "Missing digits" if string.empty?
|
|
122
|
+
|
|
123
|
+
if expected && string.length != expected
|
|
124
|
+
raise ArgumentError, "Invalid digit length"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if max && string.length > max
|
|
128
|
+
raise ArgumentError, "Invalid digit length"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
string
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def decimal(value, scale:, integer_digits:)
|
|
135
|
+
string = value.to_s.tr(",", ".").strip
|
|
136
|
+
|
|
137
|
+
unless string.match?(/\A\d+(\.\d+)?\z/)
|
|
138
|
+
raise ArgumentError, "Invalid decimal value"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
whole, fraction = string.split(".", 2)
|
|
142
|
+
|
|
143
|
+
if whole.length > integer_digits
|
|
144
|
+
raise ArgumentError, "Invalid decimal integer length"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if fraction && fraction.length > scale
|
|
148
|
+
raise ArgumentError, "Invalid decimal scale"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
format_decimal(BigDecimal(string), scale)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def format_decimal(value, scale)
|
|
155
|
+
string = value.round(scale).to_s("F")
|
|
156
|
+
whole, fraction = string.split(".", 2)
|
|
157
|
+
fraction = fraction.to_s.ljust(scale, "0")[0, scale]
|
|
158
|
+
|
|
159
|
+
"#{whole}.#{fraction}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def catalog_value!(catalog, code, field_name)
|
|
163
|
+
value = catalog[code]
|
|
164
|
+
return value unless value.nil?
|
|
165
|
+
|
|
166
|
+
raise ArgumentError, "Unknown #{field_name}: #{code}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "invoice_extractor_arca/version"
|
|
6
|
+
require_relative "invoice_extractor_arca/configuration"
|
|
7
|
+
require_relative "invoice_extractor_arca/result"
|
|
8
|
+
require_relative "invoice_extractor_arca/error"
|
|
9
|
+
require_relative "invoice_extractor_arca/errors"
|
|
10
|
+
require_relative "invoice_extractor_arca/dependencies/resolver"
|
|
11
|
+
require_relative "invoice_extractor_arca/error_record"
|
|
12
|
+
require_relative "invoice_extractor_arca/command_runner"
|
|
13
|
+
require_relative "invoice_extractor_arca/text/locale_value_normalizer"
|
|
14
|
+
require_relative "invoice_extractor_arca/text/layout_table_parser"
|
|
15
|
+
require_relative "invoice_extractor_arca/text/invoice_parser"
|
|
16
|
+
require_relative "invoice_extractor_arca/validation/reconciler"
|
|
17
|
+
require_relative "invoice_extractor_arca/extractor"
|
|
18
|
+
require_relative "invoice_extractor_arca/scanners/zbar_scanner"
|
|
19
|
+
require_relative "invoice_extractor_arca/scanners/qreader_scanner"
|
|
20
|
+
require_relative "invoice_extractor_arca/scanners/scanner_chain"
|
|
21
|
+
require_relative "invoice_extractor_arca/scanners/factory"
|
|
22
|
+
require_relative "invoice_extractor_arca/pdf/renderer"
|
|
23
|
+
require_relative "invoice_extractor_arca/pdf/ocr_text_extractor"
|
|
24
|
+
require_relative "invoice_extractor_arca/qr/payload_parser"
|
|
25
|
+
require_relative "invoice_extractor_arca/qr/result_builder"
|
|
26
|
+
require_relative "invoice_extractor_arca/qr/reader"
|
|
27
|
+
require_relative "invoice_extractor_arca/qr_extractor"
|
|
28
|
+
require_relative "invoice_extractor_arca/catalogs"
|
|
29
|
+
require_relative "invoice_extractor_arca/voucher_validator"
|
|
30
|
+
require_relative "invoice_extractor_arca/text/normalizer"
|
|
31
|
+
require_relative "invoice_extractor_arca/pdf/text_extractor"
|
|
32
|
+
require_relative "invoice_extractor_arca/ocr/rapidocr_text_extractor"
|
|
33
|
+
require_relative "invoice_extractor_arca/cli"
|
|
34
|
+
require_relative "invoice_extractor_arca/final_payload_builder"
|
|
35
|
+
|
|
36
|
+
module InvoiceExtractorArca
|
|
37
|
+
IMAGE_EXTENSIONS = %w[
|
|
38
|
+
.png
|
|
39
|
+
.jpg
|
|
40
|
+
.jpeg
|
|
41
|
+
.webp
|
|
42
|
+
.bmp
|
|
43
|
+
.tif
|
|
44
|
+
.tiff
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def extract(path, **options)
|
|
50
|
+
Extractor.new(**extractor_options(options)).extract(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_pdf(path, **options)
|
|
54
|
+
validate_pdf_extension!(path)
|
|
55
|
+
extract(path, **options)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extract_image(path, **options)
|
|
59
|
+
validate_image_extension!(path)
|
|
60
|
+
extract(path, **options)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_dependencies(dependency_paths: {})
|
|
64
|
+
Dependencies::Resolver.new(
|
|
65
|
+
custom_paths: dependency_paths
|
|
66
|
+
).check_all
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_dependencies!(dependency_paths: {})
|
|
70
|
+
Dependencies::Resolver.new(
|
|
71
|
+
custom_paths: dependency_paths
|
|
72
|
+
).resolve_all!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extractor_options(options)
|
|
76
|
+
allowed = %i[
|
|
77
|
+
strict
|
|
78
|
+
dependency_paths
|
|
79
|
+
page_range
|
|
80
|
+
resolution
|
|
81
|
+
multiple_qr
|
|
82
|
+
qr_scanner
|
|
83
|
+
python_command
|
|
84
|
+
resolver
|
|
85
|
+
runner
|
|
86
|
+
qr_extractor
|
|
87
|
+
voucher_validator
|
|
88
|
+
enrichment
|
|
89
|
+
text_extractor
|
|
90
|
+
invoice_parser
|
|
91
|
+
reconciler
|
|
92
|
+
image_text_extractor
|
|
93
|
+
pdf_ocr_text_extractor
|
|
94
|
+
final_payload_builder
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
options.slice(*allowed)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method :extractor_options
|
|
101
|
+
|
|
102
|
+
def validate_pdf_extension!(path)
|
|
103
|
+
return if File.extname(path).downcase == ".pdf"
|
|
104
|
+
|
|
105
|
+
raise Errors::UnsupportedFileType,
|
|
106
|
+
"Expected a PDF file: #{path}"
|
|
107
|
+
end
|
|
108
|
+
private_class_method :validate_pdf_extension!
|
|
109
|
+
|
|
110
|
+
def validate_image_extension!(path)
|
|
111
|
+
return if IMAGE_EXTENSIONS.include?(File.extname(path).downcase)
|
|
112
|
+
|
|
113
|
+
raise Errors::UnsupportedFileType,
|
|
114
|
+
"Expected an image file: #{path}"
|
|
115
|
+
end
|
|
116
|
+
private_class_method :validate_image_extension!
|
|
117
|
+
end
|