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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module InvoiceExtractorArca
|
|
6
|
+
module Dependencies
|
|
7
|
+
class Resolver
|
|
8
|
+
REQUIRED = %i[
|
|
9
|
+
pdftoppm
|
|
10
|
+
pdftotext
|
|
11
|
+
zbarimg
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(custom_paths: {}, path_env: ENV["PATH"])
|
|
15
|
+
@custom_paths = normalize_keys(custom_paths || {})
|
|
16
|
+
@path_env = path_env.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve!(name)
|
|
20
|
+
name = name.to_sym
|
|
21
|
+
custom_path = @custom_paths[name]
|
|
22
|
+
|
|
23
|
+
return validate_custom!(name, custom_path) if custom_path
|
|
24
|
+
|
|
25
|
+
find_on_path(name.to_s) || raise_missing(name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_all!(*names)
|
|
29
|
+
names = REQUIRED if names.empty?
|
|
30
|
+
|
|
31
|
+
missing = []
|
|
32
|
+
resolved = {}
|
|
33
|
+
|
|
34
|
+
names.each do |name|
|
|
35
|
+
resolved[name.to_sym] = resolve!(name)
|
|
36
|
+
rescue Errors::MissingDependency
|
|
37
|
+
missing << name.to_sym
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise_missing_group(missing) unless missing.empty?
|
|
41
|
+
|
|
42
|
+
resolved
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_all(*names)
|
|
46
|
+
names = REQUIRED if names.empty?
|
|
47
|
+
|
|
48
|
+
names.each_with_object({}) do |name, results|
|
|
49
|
+
results[name.to_sym] = {
|
|
50
|
+
available: true,
|
|
51
|
+
path: resolve!(name),
|
|
52
|
+
error: nil
|
|
53
|
+
}
|
|
54
|
+
rescue Errors::MissingDependency => e
|
|
55
|
+
results[name.to_sym] = {
|
|
56
|
+
available: false,
|
|
57
|
+
path: nil,
|
|
58
|
+
error: e.message
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalize_keys(hash)
|
|
66
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
67
|
+
normalized[key.to_sym] = value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_custom!(name, path)
|
|
72
|
+
expanded = File.expand_path(path.to_s)
|
|
73
|
+
|
|
74
|
+
return expanded if File.file?(expanded) && File.executable?(expanded)
|
|
75
|
+
|
|
76
|
+
raise Errors::MissingDependency.new(
|
|
77
|
+
"Configured #{name} is not executable: #{expanded}",
|
|
78
|
+
missing: [name]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def find_on_path(command)
|
|
83
|
+
@path_env.split(File::PATH_SEPARATOR).each do |directory|
|
|
84
|
+
next if directory.to_s.empty?
|
|
85
|
+
|
|
86
|
+
candidate = File.join(directory, command)
|
|
87
|
+
|
|
88
|
+
if File.file?(candidate) && File.executable?(candidate)
|
|
89
|
+
return File.expand_path(candidate)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def raise_missing(name)
|
|
97
|
+
raise Errors::MissingDependency.new(
|
|
98
|
+
missing_dependency_message([name]),
|
|
99
|
+
missing: [name]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def raise_missing_group(names)
|
|
104
|
+
raise Errors::MissingDependency.new(
|
|
105
|
+
missing_dependency_message(names),
|
|
106
|
+
missing: names
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def missing_dependency_message(names)
|
|
111
|
+
commands = names.join(", ")
|
|
112
|
+
|
|
113
|
+
"Missing external dependency: #{commands}. " \
|
|
114
|
+
"Install the missing command or configure dependency_paths."
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InvoiceExtractorArca
|
|
4
|
+
class ErrorRecord
|
|
5
|
+
attr_reader :code, :message, :severity, :component, :details
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
code:,
|
|
9
|
+
message:,
|
|
10
|
+
severity: "error",
|
|
11
|
+
component: nil,
|
|
12
|
+
details: {}
|
|
13
|
+
)
|
|
14
|
+
@code = code.to_s
|
|
15
|
+
@message = message.to_s
|
|
16
|
+
@severity = severity.to_s
|
|
17
|
+
@component = component&.to_s
|
|
18
|
+
@details = details || {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def error?
|
|
22
|
+
severity == "error"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warning?
|
|
26
|
+
severity == "warning"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
code: code,
|
|
32
|
+
message: message,
|
|
33
|
+
severity: severity,
|
|
34
|
+
component: component,
|
|
35
|
+
details: details
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module InvoiceExtractorArca
|
|
6
|
+
module Errors
|
|
7
|
+
class MissingDependency < Error
|
|
8
|
+
attr_reader :missing
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, missing: [])
|
|
11
|
+
@missing = Array(missing)
|
|
12
|
+
super(message || "Missing external dependency: #{missing.join(", ")}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class UnsupportedFileType < Error
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class UnreadableFile < Error
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class QrNotFound < Error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class InvalidVoucherSchema < Error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class MultipleQrFound < Error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class PdfTextExtractionFailure < Error
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "result"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "error_record"
|
|
7
|
+
require_relative "command_runner"
|
|
8
|
+
require_relative "dependencies/resolver"
|
|
9
|
+
require_relative "qr_extractor"
|
|
10
|
+
require_relative "voucher_validator"
|
|
11
|
+
require_relative "pdf/text_extractor"
|
|
12
|
+
require_relative "text/normalizer"
|
|
13
|
+
require_relative "ocr/rapidocr_text_extractor"
|
|
14
|
+
require_relative "final_payload_builder"
|
|
15
|
+
|
|
16
|
+
module InvoiceExtractorArca
|
|
17
|
+
class Extractor
|
|
18
|
+
def initialize(
|
|
19
|
+
strict: false,
|
|
20
|
+
dependency_paths: {},
|
|
21
|
+
page_range: nil,
|
|
22
|
+
resolution: 300,
|
|
23
|
+
multiple_qr: :first_valid,
|
|
24
|
+
qr_scanner: :zbar_then_qreader,
|
|
25
|
+
python_command: :python3,
|
|
26
|
+
resolver: nil,
|
|
27
|
+
runner: CommandRunner.new,
|
|
28
|
+
qr_extractor: nil,
|
|
29
|
+
voucher_validator: VoucherValidator.new,
|
|
30
|
+
enrichment: true,
|
|
31
|
+
text_extractor: nil,
|
|
32
|
+
invoice_parser: nil,
|
|
33
|
+
reconciler: nil,
|
|
34
|
+
image_text_extractor: nil,
|
|
35
|
+
pdf_ocr_text_extractor: nil,
|
|
36
|
+
final_payload_builder: FinalPayloadBuilder.new
|
|
37
|
+
)
|
|
38
|
+
@configuration = Configuration.new(
|
|
39
|
+
strict: strict,
|
|
40
|
+
dependency_paths: dependency_paths,
|
|
41
|
+
page_range: page_range,
|
|
42
|
+
resolution: resolution,
|
|
43
|
+
multiple_qr: multiple_qr,
|
|
44
|
+
qr_scanner: qr_scanner,
|
|
45
|
+
python_command: python_command,
|
|
46
|
+
enrichment: enrichment
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@resolver =
|
|
50
|
+
resolver ||
|
|
51
|
+
Dependencies::Resolver.new(
|
|
52
|
+
custom_paths: @configuration.dependency_paths
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@runner = runner
|
|
56
|
+
@voucher_validator = voucher_validator
|
|
57
|
+
@text_extractor =
|
|
58
|
+
text_extractor ||
|
|
59
|
+
Pdf::TextExtractor.new(
|
|
60
|
+
resolver: @resolver,
|
|
61
|
+
runner: @runner
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@invoice_parser =
|
|
65
|
+
invoice_parser ||
|
|
66
|
+
Text::InvoiceParser.new
|
|
67
|
+
|
|
68
|
+
@reconciler =
|
|
69
|
+
reconciler ||
|
|
70
|
+
Validation::Reconciler.new
|
|
71
|
+
|
|
72
|
+
@qr_extractor =
|
|
73
|
+
qr_extractor ||
|
|
74
|
+
QrExtractor.new(
|
|
75
|
+
resolver: @resolver,
|
|
76
|
+
runner: @runner,
|
|
77
|
+
page_range: effective_page_range,
|
|
78
|
+
resolution: @configuration.resolution,
|
|
79
|
+
multiple_qr: @configuration.multiple_qr,
|
|
80
|
+
qr_scanner: @configuration.qr_scanner,
|
|
81
|
+
python_command: @configuration.python_command
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@image_text_extractor =
|
|
85
|
+
image_text_extractor ||
|
|
86
|
+
Ocr::RapidocrTextExtractor.new(
|
|
87
|
+
resolver: @resolver,
|
|
88
|
+
runner: @runner,
|
|
89
|
+
python_command: @configuration.python_command
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@pdf_ocr_text_extractor =
|
|
93
|
+
pdf_ocr_text_extractor ||
|
|
94
|
+
Pdf::OcrTextExtractor.new(
|
|
95
|
+
renderer: Pdf::Renderer.new(
|
|
96
|
+
resolver: @resolver,
|
|
97
|
+
runner: @runner
|
|
98
|
+
),
|
|
99
|
+
image_text_extractor: @image_text_extractor,
|
|
100
|
+
resolution: @configuration.resolution
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@final_payload_builder = final_payload_builder
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract(file_path)
|
|
107
|
+
validate_file!(file_path)
|
|
108
|
+
|
|
109
|
+
qr_result = @qr_extractor.extract(file_path)
|
|
110
|
+
voucher, errors = build_voucher_and_errors(qr_result)
|
|
111
|
+
|
|
112
|
+
return gated_failure(file_path, qr_result, voucher, errors) if errors.any?(&:error?)
|
|
113
|
+
|
|
114
|
+
text = extract_text(file_path)
|
|
115
|
+
unless text[:extracted]
|
|
116
|
+
diagnostics = text_warning_records(text)
|
|
117
|
+
|
|
118
|
+
return Result.new(
|
|
119
|
+
source: {path: file_path},
|
|
120
|
+
qr: qr_result,
|
|
121
|
+
voucher: voucher,
|
|
122
|
+
text: text,
|
|
123
|
+
enrichment: {},
|
|
124
|
+
validation: {
|
|
125
|
+
status: diagnostics.empty? ? "ok" : "warning",
|
|
126
|
+
comparisons: []
|
|
127
|
+
},
|
|
128
|
+
errors: diagnostics,
|
|
129
|
+
metadata: {}
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
parsed = @invoice_parser.parse(
|
|
133
|
+
raw: text[:raw],
|
|
134
|
+
normalized: text[:normalized],
|
|
135
|
+
voucher: voucher[:normalized]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
validation = @reconciler.call(
|
|
139
|
+
voucher: voucher[:normalized],
|
|
140
|
+
enrichment: parsed[:enrichment],
|
|
141
|
+
parsing_warnings: parsed[:diagnostics]
|
|
142
|
+
)
|
|
143
|
+
Result.new(
|
|
144
|
+
source: {path: file_path},
|
|
145
|
+
qr: qr_result,
|
|
146
|
+
voucher: voucher,
|
|
147
|
+
text: text,
|
|
148
|
+
enrichment: parsed[:enrichment],
|
|
149
|
+
validation: validation,
|
|
150
|
+
errors: parsed[:diagnostics],
|
|
151
|
+
metadata: {},
|
|
152
|
+
final: @final_payload_builder.build(
|
|
153
|
+
qr_result: qr_result,
|
|
154
|
+
voucher: voucher,
|
|
155
|
+
enrichment: parsed[:enrichment]
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
rescue Error => e
|
|
159
|
+
raise if @configuration.strict?
|
|
160
|
+
|
|
161
|
+
failure_result(file_path, e)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def gated_failure(file_path, qr_result, voucher, errors)
|
|
165
|
+
raise_strict_error!(errors.first) if @configuration.strict?
|
|
166
|
+
|
|
167
|
+
Result.new(
|
|
168
|
+
source: {path: file_path},
|
|
169
|
+
qr: qr_result,
|
|
170
|
+
voucher: voucher,
|
|
171
|
+
text: {},
|
|
172
|
+
enrichment: {},
|
|
173
|
+
validation: {status: "error", comparisons: []},
|
|
174
|
+
errors: errors,
|
|
175
|
+
metadata: {}
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def validate_file!(file_path)
|
|
182
|
+
unless File.file?(file_path) && File.readable?(file_path)
|
|
183
|
+
raise Errors::UnreadableFile,
|
|
184
|
+
"File not found or unreadable: #{file_path}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_voucher_and_errors(qr_result)
|
|
189
|
+
unless qr_result[:found]
|
|
190
|
+
return [
|
|
191
|
+
nil,
|
|
192
|
+
qr_error_records(qr_result)
|
|
193
|
+
]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
original = qr_result[:payload]
|
|
197
|
+
normalized = @voucher_validator.validate_and_normalize(original)
|
|
198
|
+
|
|
199
|
+
[
|
|
200
|
+
{
|
|
201
|
+
original: original,
|
|
202
|
+
normalized: normalized
|
|
203
|
+
},
|
|
204
|
+
[]
|
|
205
|
+
]
|
|
206
|
+
rescue Errors::InvalidVoucherSchema => e
|
|
207
|
+
[
|
|
208
|
+
{
|
|
209
|
+
original: defined?(original) ? original : nil,
|
|
210
|
+
normalized: {}
|
|
211
|
+
},
|
|
212
|
+
[
|
|
213
|
+
ErrorRecord.new(
|
|
214
|
+
code: "invalid_voucher_schema",
|
|
215
|
+
message: e.message,
|
|
216
|
+
component: "voucher"
|
|
217
|
+
)
|
|
218
|
+
]
|
|
219
|
+
]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def failure_result(file_path, error)
|
|
223
|
+
Result.new(
|
|
224
|
+
source: {
|
|
225
|
+
path: file_path
|
|
226
|
+
},
|
|
227
|
+
qr: {},
|
|
228
|
+
voucher: nil,
|
|
229
|
+
text: {},
|
|
230
|
+
enrichment: {},
|
|
231
|
+
validation: {},
|
|
232
|
+
errors: [
|
|
233
|
+
error_record_from_exception(error)
|
|
234
|
+
],
|
|
235
|
+
metadata: {}
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def qr_error_records(qr_result)
|
|
240
|
+
qr_errors = Array(qr_result[:errors])
|
|
241
|
+
|
|
242
|
+
return [default_qr_not_found_error] if qr_errors.empty?
|
|
243
|
+
|
|
244
|
+
qr_errors.map do |error|
|
|
245
|
+
ErrorRecord.new(
|
|
246
|
+
code: error[:code],
|
|
247
|
+
message: error[:message],
|
|
248
|
+
component: error[:component],
|
|
249
|
+
details: error[:details] || {}
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def default_qr_not_found_error
|
|
255
|
+
ErrorRecord.new(
|
|
256
|
+
code: "qr_not_found",
|
|
257
|
+
message: "No valid ARCA QR payload was found",
|
|
258
|
+
component: "qr"
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def error_record_from_exception(error)
|
|
263
|
+
case error
|
|
264
|
+
when Errors::UnreadableFile
|
|
265
|
+
ErrorRecord.new(
|
|
266
|
+
code: "unreadable_file",
|
|
267
|
+
message: error.message,
|
|
268
|
+
component: "source"
|
|
269
|
+
)
|
|
270
|
+
when Errors::MissingDependency
|
|
271
|
+
ErrorRecord.new(
|
|
272
|
+
code: "missing_dependency",
|
|
273
|
+
message: error.message,
|
|
274
|
+
component: "dependencies",
|
|
275
|
+
details: {
|
|
276
|
+
missing: error.missing
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
when Errors::UnsupportedFileType
|
|
280
|
+
ErrorRecord.new(
|
|
281
|
+
code: "unsupported_file_type",
|
|
282
|
+
message: error.message,
|
|
283
|
+
component: "source"
|
|
284
|
+
)
|
|
285
|
+
else
|
|
286
|
+
ErrorRecord.new(
|
|
287
|
+
code: "extraction_error",
|
|
288
|
+
message: error.message,
|
|
289
|
+
component: "extractor"
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def raise_strict_error!(error_record)
|
|
295
|
+
case error_record.code
|
|
296
|
+
when "qr_not_found"
|
|
297
|
+
raise Errors::QrNotFound, error_record.message
|
|
298
|
+
when "multiple_qr_found"
|
|
299
|
+
raise Errors::MultipleQrFound, error_record.message
|
|
300
|
+
when "invalid_voucher_schema"
|
|
301
|
+
raise Errors::InvalidVoucherSchema, error_record.message
|
|
302
|
+
else
|
|
303
|
+
raise Error, error_record.message
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def extract_text(file_path)
|
|
308
|
+
return disabled_text_result unless @configuration.enrichment?
|
|
309
|
+
|
|
310
|
+
if pdf?(file_path)
|
|
311
|
+
pdf_text = @text_extractor.extract(
|
|
312
|
+
file_path,
|
|
313
|
+
page_range: effective_page_range
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return pdf_text if pdf_text[:extracted]
|
|
317
|
+
|
|
318
|
+
empty_text = Array(pdf_text[:warnings]).any? do |warning|
|
|
319
|
+
warning[:code] == "pdf_text_empty"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
return pdf_text unless empty_text
|
|
323
|
+
|
|
324
|
+
@pdf_ocr_text_extractor.extract(
|
|
325
|
+
file_path,
|
|
326
|
+
page_range: effective_page_range
|
|
327
|
+
)
|
|
328
|
+
else
|
|
329
|
+
@image_text_extractor.extract(file_path)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def disabled_text_result
|
|
334
|
+
{
|
|
335
|
+
extracted: false,
|
|
336
|
+
source: nil,
|
|
337
|
+
raw: "",
|
|
338
|
+
normalized: "",
|
|
339
|
+
pages: nil,
|
|
340
|
+
warnings: [
|
|
341
|
+
{
|
|
342
|
+
code: "enrichment_disabled",
|
|
343
|
+
message: "Text extraction is disabled",
|
|
344
|
+
component: "text"
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def not_applicable_text_result
|
|
351
|
+
{
|
|
352
|
+
extracted: false,
|
|
353
|
+
source: nil,
|
|
354
|
+
raw: "",
|
|
355
|
+
normalized: "",
|
|
356
|
+
pages: nil,
|
|
357
|
+
warnings: []
|
|
358
|
+
}
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def pdf?(file_path)
|
|
362
|
+
File.extname(file_path).downcase == ".pdf"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def text_warning_records(text)
|
|
366
|
+
Array(text[:warnings]).map do |warning|
|
|
367
|
+
ErrorRecord.new(
|
|
368
|
+
code: warning[:code],
|
|
369
|
+
message: warning[:message],
|
|
370
|
+
severity: "warning",
|
|
371
|
+
component: warning[:component] || "text",
|
|
372
|
+
details: warning[:details] || {}
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def effective_page_range
|
|
378
|
+
@configuration.page_range || 1
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InvoiceExtractorArca
|
|
4
|
+
class FinalPayloadBuilder
|
|
5
|
+
def build(qr_result:, voucher:, enrichment:)
|
|
6
|
+
{
|
|
7
|
+
qr_payload: qr_result[:payload] || {},
|
|
8
|
+
normalized: normalized_fields(voucher, enrichment),
|
|
9
|
+
enrichment: serialize_enrichment(enrichment)
|
|
10
|
+
}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def normalized_fields(voucher, enrichment)
|
|
16
|
+
qr_fields = voucher&.dig(:normalized) || {}
|
|
17
|
+
|
|
18
|
+
enrichment_fields =
|
|
19
|
+
enrichment.each_with_object({}) do |(name, field), result|
|
|
20
|
+
next if name == :service_rows
|
|
21
|
+
next unless field.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
result[name] = field[:normalized]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# QR values are authoritative when names overlap.
|
|
27
|
+
enrichment_fields.merge(qr_fields)
|
|
28
|
+
.merge(
|
|
29
|
+
service_rows: normalized_service_rows(
|
|
30
|
+
enrichment[:service_rows]
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalized_service_rows(rows)
|
|
36
|
+
Array(rows).map do |row|
|
|
37
|
+
row.each_with_object({}) do |(name, field), result|
|
|
38
|
+
result[name] =
|
|
39
|
+
if field.is_a?(Hash)
|
|
40
|
+
field[:normalized]
|
|
41
|
+
else
|
|
42
|
+
field
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def serialize_enrichment(enrichment)
|
|
49
|
+
enrichment.each_with_object({}) do |(name, value), result|
|
|
50
|
+
result[name] =
|
|
51
|
+
if name == :service_rows
|
|
52
|
+
Array(value)
|
|
53
|
+
elsif value.is_a?(Hash)
|
|
54
|
+
{
|
|
55
|
+
raw: value[:raw],
|
|
56
|
+
normalized: value[:normalized],
|
|
57
|
+
source: value[:source],
|
|
58
|
+
method: value[:method],
|
|
59
|
+
confidence: value[:confidence]
|
|
60
|
+
}.compact
|
|
61
|
+
else
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|