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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +631 -0
  5. data/exe/arca-invoice-extract +6 -0
  6. data/lib/invoice_extractor_arca/catalogs/copy_types.json +5 -0
  7. data/lib/invoice_extractor_arca/catalogs/currencies.json +65 -0
  8. data/lib/invoice_extractor_arca/catalogs/document_types.json +40 -0
  9. data/lib/invoice_extractor_arca/catalogs/iva_types.json +13 -0
  10. data/lib/invoice_extractor_arca/catalogs/payment_methods.json +10 -0
  11. data/lib/invoice_extractor_arca/catalogs/units_measure.json +47 -0
  12. data/lib/invoice_extractor_arca/catalogs/voucher_types.json +99 -0
  13. data/lib/invoice_extractor_arca/catalogs.rb +34 -0
  14. data/lib/invoice_extractor_arca/cli.rb +247 -0
  15. data/lib/invoice_extractor_arca/command_runner.rb +18 -0
  16. data/lib/invoice_extractor_arca/configuration.rb +42 -0
  17. data/lib/invoice_extractor_arca/dependencies/resolver.rb +118 -0
  18. data/lib/invoice_extractor_arca/error.rb +6 -0
  19. data/lib/invoice_extractor_arca/error_record.rb +39 -0
  20. data/lib/invoice_extractor_arca/errors.rb +34 -0
  21. data/lib/invoice_extractor_arca/extractor.rb +381 -0
  22. data/lib/invoice_extractor_arca/final_payload_builder.rb +67 -0
  23. data/lib/invoice_extractor_arca/ocr/rapidocr_extract.py +198 -0
  24. data/lib/invoice_extractor_arca/ocr/rapidocr_text_extractor.rb +117 -0
  25. data/lib/invoice_extractor_arca/pdf/ocr_text_extractor.rb +131 -0
  26. data/lib/invoice_extractor_arca/pdf/renderer.rb +97 -0
  27. data/lib/invoice_extractor_arca/pdf/text_extractor.rb +182 -0
  28. data/lib/invoice_extractor_arca/qr/payload_parser.rb +124 -0
  29. data/lib/invoice_extractor_arca/qr/reader.rb +53 -0
  30. data/lib/invoice_extractor_arca/qr/result_builder.rb +229 -0
  31. data/lib/invoice_extractor_arca/qr_extractor.rb +156 -0
  32. data/lib/invoice_extractor_arca/result.rb +94 -0
  33. data/lib/invoice_extractor_arca/scanners/factory.rb +88 -0
  34. data/lib/invoice_extractor_arca/scanners/qreader_scan.py +102 -0
  35. data/lib/invoice_extractor_arca/scanners/qreader_scanner.rb +131 -0
  36. data/lib/invoice_extractor_arca/scanners/scanner_chain.rb +73 -0
  37. data/lib/invoice_extractor_arca/scanners/zbar_scanner.rb +52 -0
  38. data/lib/invoice_extractor_arca/text/invoice_parser.rb +517 -0
  39. data/lib/invoice_extractor_arca/text/layout_table_parser.rb +218 -0
  40. data/lib/invoice_extractor_arca/text/locale_value_normalizer.rb +53 -0
  41. data/lib/invoice_extractor_arca/text/normalizer.rb +31 -0
  42. data/lib/invoice_extractor_arca/validation/reconciler.rb +53 -0
  43. data/lib/invoice_extractor_arca/version.rb +5 -0
  44. data/lib/invoice_extractor_arca/voucher_validator.rb +169 -0
  45. data/lib/invoice_extractor_arca.rb +117 -0
  46. data/requirements-ocr.txt +2 -0
  47. data/sig/invoice_extractor_arca.rbs +4 -0
  48. metadata +135 -0
@@ -0,0 +1,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require_relative "../catalogs"
6
+ require_relative "layout_table_parser"
7
+ require_relative "locale_value_normalizer"
8
+ require_relative "../error_record"
9
+
10
+ module InvoiceExtractorArca
11
+ module Text
12
+ class InvoiceParser
13
+ SCALAR_FIELDS = %i[
14
+ copy_type
15
+ voucher_type_code
16
+ trade_name
17
+ biller_address
18
+ biller_iva_condition
19
+ selling_point
20
+ invoice_number
21
+ emission_date
22
+ biller_cuit
23
+ gross_income_number
24
+ operations_start_date
25
+ billing_date_from
26
+ billing_date_to
27
+ billing_due_date
28
+ recipient_document_type
29
+ recipient_document_number
30
+ recipient_iva_condition
31
+ payment_method
32
+ recipient_name
33
+ recipient_address
34
+ general_subtotal
35
+ other_taxes
36
+ total
37
+ authorization_code
38
+ authorization_due_date
39
+ ].freeze
40
+
41
+ def initialize(
42
+ value_normalizer: LocaleValueNormalizer.new,
43
+ table_parser: nil
44
+ )
45
+ @value_normalizer = value_normalizer
46
+ @table_parser =
47
+ table_parser ||
48
+ LayoutTableParser.new(value_normalizer: value_normalizer)
49
+ end
50
+
51
+ def parse(raw:, normalized:, voucher:)
52
+ text = normalized.to_s.strip
53
+ text = raw.to_s if text.empty?
54
+
55
+ document_regions = regions(text)
56
+ table_result = @table_parser.parse(raw)
57
+ diagnostics = Array(table_result[:diagnostics])
58
+ enrichment = {}
59
+
60
+ set_field(enrichment, :copy_type, copy_type(text))
61
+ set_field(enrichment, :voucher_type_code, voucher_type_code(text))
62
+ set_field(enrichment, :selling_point, selling_point(text))
63
+ set_field(enrichment, :invoice_number, invoice_number(text))
64
+ set_field(enrichment, :emission_date, emission_date(text))
65
+ set_field(enrichment, :biller_cuit, biller_cuit(text))
66
+ set_field(
67
+ enrichment,
68
+ :recipient_document_type,
69
+ recipient_document_type(document_regions[:recipient], voucher)
70
+ )
71
+ set_field(enrichment, :recipient_document_number, recipient_document_number(text))
72
+ set_field(enrichment, :total, total(text))
73
+ set_field(enrichment, :authorization_code, authorization_code(text))
74
+ set_field(
75
+ enrichment,
76
+ :biller_cuit,
77
+ biller_cuit(document_regions[:header])
78
+ )
79
+
80
+ set_field(
81
+ enrichment,
82
+ :recipient_document_number,
83
+ recipient_document_number(document_regions[:recipient])
84
+ )
85
+ set_field(enrichment, :trade_name, trade_name(document_regions[:header]))
86
+ set_field(enrichment, :biller_address, biller_address(document_regions[:header]))
87
+ set_field(enrichment, :biller_iva_condition, biller_iva_condition(document_regions[:header]))
88
+ set_field(enrichment, :gross_income_number, gross_income_number(document_regions[:header]))
89
+ set_field(enrichment, :operations_start_date, operations_start_date(document_regions[:header]))
90
+
91
+ set_field(enrichment, :billing_date_from, billing_date_from(document_regions[:billing]))
92
+ set_field(enrichment, :billing_date_to, billing_date_to(document_regions[:billing]))
93
+ set_field(enrichment, :billing_due_date, billing_due_date(document_regions[:billing]))
94
+
95
+ set_field(enrichment, :recipient_iva_condition, recipient_iva_condition(document_regions[:recipient]))
96
+ set_field(enrichment, :payment_method, payment_method(document_regions[:recipient]))
97
+ set_field(enrichment, :recipient_name, recipient_name(document_regions[:recipient]))
98
+ set_field(enrichment, :recipient_address, recipient_address(document_regions[:recipient]))
99
+
100
+ set_field(enrichment, :general_subtotal, general_subtotal(document_regions[:totals]))
101
+ set_field(enrichment, :other_taxes, other_taxes(document_regions[:totals]))
102
+ set_field(enrichment, :authorization_due_date, authorization_due_date(document_regions[:authorization]))
103
+
104
+ SCALAR_FIELDS.each do |name|
105
+ next if enrichment.key?(name)
106
+
107
+ enrichment[name] = missing_field
108
+ diagnostics << missing_field_diagnostic(name)
109
+ end
110
+
111
+ enrichment[:service_rows] = table_result[:rows]
112
+
113
+ {
114
+ enrichment: enrichment,
115
+ diagnostics: diagnostics
116
+ }
117
+ end
118
+
119
+ def regions(text)
120
+ {
121
+ header: bounded_region(
122
+ text,
123
+ /\A/,
124
+ /Per[ií]odo\s+Facturado\s+Desde/i
125
+ ),
126
+ billing: bounded_region(
127
+ text,
128
+ /Per[ií]odo\s+Facturado\s+Desde/i,
129
+ /^CUIT\s*:/i
130
+ ),
131
+ recipient: bounded_region(
132
+ text,
133
+ /^CUIT\s*:/i,
134
+ /^C[oó]digo\s+Producto\s*\/\s*Servicio/i
135
+ ),
136
+ totals: bounded_region(
137
+ text,
138
+ /Subtotal\s*:\s*\$/i,
139
+ /CAE(?:A)?\s+N[°ºo]?/i
140
+ ),
141
+ authorization: bounded_region(
142
+ text,
143
+ /CAE(?:A)?\s+N[°ºo]?/i,
144
+ /\z/
145
+ )
146
+ }
147
+ end
148
+
149
+ def bounded_region(text, start_pattern, end_pattern)
150
+ start_match = text.match(start_pattern)
151
+ return "" unless start_match
152
+
153
+ remainder = text[start_match.begin(0)..]
154
+ end_match = remainder.match(end_pattern)
155
+
156
+ if end_match&.begin(0)&.positive?
157
+ remainder[0...end_match.begin(0)]
158
+ else
159
+ remainder
160
+ end
161
+ end
162
+
163
+ def trade_name(text)
164
+ match = text.match(
165
+ /Raz[oó]n\s+Social\s*:\s*([^\n]{1,100}?)(?=\s{2,}Fecha\s+de\s+Emisi[oó]n|\n|\z)/i
166
+ )
167
+ return nil unless match
168
+
169
+ field(match[1])
170
+ end
171
+
172
+ def biller_address(text)
173
+ labeled_wrapped_value(
174
+ text,
175
+ /Domicilio\s+Comercial\s*:/i,
176
+ /CUIT\s*:|Condici[oó]n\s+frente\s+al\s+IVA/i
177
+ )
178
+ end
179
+
180
+ def biller_iva_condition(text)
181
+ catalog_value_after_label(
182
+ text,
183
+ /Condici[oó]n\s+frente\s+al\s+IVA\s*:/i,
184
+ Catalogs::IVA_TYPES
185
+ )
186
+ end
187
+
188
+ def gross_income_number(text)
189
+ digits_field(text, /Ingresos\s+Brutos\s*:/i)
190
+ end
191
+
192
+ def operations_start_date(text)
193
+ date_field(text, /Fecha\s+de\s+Inicio\s+de\s+Actividades\s*:/i)
194
+ end
195
+
196
+ def billing_date_from(text)
197
+ date_field(text, /Per[ií]odo\s+Facturado\s+Desde\s*:/i)
198
+ end
199
+
200
+ def billing_date_to(text)
201
+ date_field(text, /\bHasta\s*:/i)
202
+ end
203
+
204
+ def billing_due_date(text)
205
+ date_field(text, /Fecha\s+de\s+Vto\.\s+para\s+el\s+pago\s*:/i)
206
+ end
207
+
208
+ def recipient_iva_condition(text)
209
+ catalog_value_after_label(
210
+ text,
211
+ /Condici[oó]n\s+frente\s+al\s+IVA\s*:/i,
212
+ Catalogs::IVA_TYPES
213
+ )
214
+ end
215
+
216
+ def payment_method(text)
217
+ catalog_field(
218
+ labeled_value(
219
+ text,
220
+ /Condici[oó]n\s+de\s+venta\s*:/i
221
+ ),
222
+ Catalogs::PAYMENT_METHODS
223
+ )
224
+ end
225
+
226
+ def recipient_name(text)
227
+ labeled_value_field(
228
+ text,
229
+ /Apellido\s+y\s+Nombre\s*\/\s*Raz[oó]n\s+Social\s*:/i
230
+ )
231
+ end
232
+
233
+ def recipient_address(text)
234
+ labeled_wrapped_value(
235
+ text,
236
+ /Domicilio\s*:/i,
237
+ /Condici[oó]n\s+de\s+venta/i
238
+ )
239
+ end
240
+
241
+ def general_subtotal(text)
242
+ money_field(text, /Subtotal\s*:\s*\$/i)
243
+ end
244
+
245
+ def other_taxes(text)
246
+ money_field(text, /Importe\s+Otros\s+Tributos\s*:\s*\$/i)
247
+ end
248
+
249
+ def authorization_due_date(text)
250
+ date_field(text, /Fecha\s+de\s+Vto\.\s+de\s+CAE(?:A)?\s*:/i)
251
+ end
252
+
253
+ private
254
+
255
+ def set_field(enrichment, name, value)
256
+ return if value.nil?
257
+
258
+ enrichment[name] = value
259
+ end
260
+
261
+ def field(
262
+ raw,
263
+ normalized: nil,
264
+ source: "pdf_text",
265
+ method: "label_regex",
266
+ confidence: "high",
267
+ description: nil
268
+ )
269
+ cleaned = raw.to_s.strip
270
+ normalized = cleaned if normalized.nil?
271
+
272
+ result = {
273
+ raw: cleaned,
274
+ normalized: normalized,
275
+ source: source,
276
+ method: method,
277
+ confidence: confidence
278
+ }
279
+
280
+ result[:description] = description if description
281
+ result
282
+ end
283
+
284
+ def copy_type(text)
285
+ catalog = Catalogs::COPY_TYPES.values.sort_by { |value| -value.length }
286
+ match = catalog.find { |value| text.match?(/\b#{Regexp.escape(value)}\b/i) }
287
+ return nil unless match
288
+
289
+ field(match, normalized: match.upcase)
290
+ end
291
+
292
+ def voucher_type_code(text)
293
+ match = text.match(/(?:COD\.?|C[oó]d\.?)\s*(\d{3})/i)
294
+ return nil unless match
295
+
296
+ code = match[1]
297
+
298
+ field(
299
+ code,
300
+ normalized: code,
301
+ method: "label_regex",
302
+ description: Catalogs::VOUCHER_TYPES[code]
303
+ )
304
+ end
305
+
306
+ def selling_point(text)
307
+ match = text.match(
308
+ /Punto\s+de\s+Venta\s*:\s*(\d{1,5})/i
309
+ )
310
+ return nil unless match
311
+
312
+ field(
313
+ match[1],
314
+ normalized: @value_normalizer.code(match[1], 5)
315
+ )
316
+ end
317
+
318
+ def invoice_number(text)
319
+ match = text.match(/Comp\.?\s*Nro:\s*(\d+)/i)
320
+ return nil unless match
321
+
322
+ normalized = match[1].to_i.to_s.rjust(8, "0")
323
+ field(match[1], normalized: normalized)
324
+ end
325
+
326
+ def emission_date(text)
327
+ match = text.match(/Fecha de Emisi[oó]n:\s*(\d{2}\/\d{2}\/\d{4})/i)
328
+ return nil unless match
329
+
330
+ normalized = Date.strptime(match[1], "%d/%m/%Y").iso8601
331
+ field(match[1], normalized: normalized)
332
+ rescue Date::Error
333
+ nil
334
+ end
335
+
336
+ def biller_cuit(text)
337
+ matches = text.scan(/\bCUIT:\s*(\d{11})\b/i)
338
+ return nil if matches.empty?
339
+
340
+ field(matches.first.first, normalized: matches.first.first)
341
+ end
342
+
343
+ def recipient_document_type(text, voucher)
344
+ expected =
345
+ voucher[:recipient_document_type_description]
346
+
347
+ return nil unless expected
348
+
349
+ pattern = /(?:#{Regexp.escape(expected)})\s*:/i
350
+ match = text.match(pattern)
351
+ return nil unless match
352
+
353
+ field(
354
+ match[0].sub(/:\s*\z/, ""),
355
+ normalized: expected
356
+ )
357
+ end
358
+
359
+ def recipient_document_number(text)
360
+ matches = text.scan(/\bCUIT:\s*(\d{11})\b/i)
361
+ return nil if matches.length < 2
362
+
363
+ field(matches[1].first, normalized: matches[1].first)
364
+ end
365
+
366
+ def total(text)
367
+ match = text.match(/Importe Total:\s*\$\s*([\d.,]+)/i)
368
+ match ||= text.match(/Subtotal:\s*\$\s*([\d.,]+)/i)
369
+ return nil unless match
370
+
371
+ normalized = @value_normalizer.decimal(match[1])
372
+ field(match[1], normalized: normalized)
373
+ end
374
+
375
+ def authorization_code(text)
376
+ match = text.match(/CAE(?:A)?\s*N[°ºo]?:\s*(\d{14})/i)
377
+ return nil unless match
378
+
379
+ field(match[1], normalized: match[1])
380
+ end
381
+
382
+ def labeled_value(text, label)
383
+ match = text.match(/#{label.source}\s*([^\n]{1,160})/i)
384
+ match && match[1].strip
385
+ end
386
+
387
+ def labeled_value_field(text, label)
388
+ value = labeled_value(text, label)
389
+ value && field(value)
390
+ end
391
+
392
+ def date_field(text, label)
393
+ match = text.match(
394
+ /#{label.source}\s*(\d{2}\/\d{2}\/\d{4})/i
395
+ )
396
+ return nil unless match
397
+
398
+ field(
399
+ match[1],
400
+ normalized: @value_normalizer.date(match[1])
401
+ )
402
+ end
403
+
404
+ def digits_field(text, label)
405
+ match = text.match(
406
+ /#{label.source}\s*(\d[\d.\-\s]{1,20})/i
407
+ )
408
+ return nil unless match
409
+
410
+ field(
411
+ match[1],
412
+ normalized: @value_normalizer.digits(match[1])
413
+ )
414
+ end
415
+
416
+ def money_field(text, label)
417
+ match = text.match(
418
+ /#{label.source}\s*([\d.]+,\d{2})/i
419
+ )
420
+ return nil unless match
421
+
422
+ field(
423
+ match[1],
424
+ normalized: @value_normalizer.decimal(match[1])
425
+ )
426
+ end
427
+
428
+ def labeled_wrapped_value(text, label, terminator)
429
+ match = text.match(
430
+ /#{label.source}\s*(.{1,240}?)(?=#{terminator.source}|\z)/im
431
+ )
432
+ return nil unless match
433
+
434
+ raw = match[1].strip
435
+ normalized = raw.lines.map(&:strip).reject(&:empty?).join(" ")
436
+
437
+ field(raw, normalized: normalized)
438
+ end
439
+
440
+ def catalog_field(raw, catalog)
441
+ return nil if raw.nil? || raw.empty?
442
+
443
+ canonical = catalog.values.find do |candidate|
444
+ comparable_text(candidate) == comparable_text(raw)
445
+ end
446
+
447
+ return field(raw, normalized: canonical) if canonical
448
+
449
+ field(
450
+ raw,
451
+ normalized: nil,
452
+ confidence: "low"
453
+ )
454
+ end
455
+
456
+ def comparable_text(value)
457
+ value
458
+ .to_s
459
+ .unicode_normalize(:nfd)
460
+ .gsub(/\p{Mn}/, "")
461
+ .downcase
462
+ .gsub(/[[:punct:]]/, " ")
463
+ .gsub(/\s+/, " ")
464
+ .strip
465
+ end
466
+
467
+ def missing_field
468
+ {
469
+ raw: nil,
470
+ normalized: nil,
471
+ source: "pdf_text",
472
+ method: "heuristic",
473
+ confidence: "low"
474
+ }
475
+ end
476
+
477
+ def missing_field_diagnostic(name)
478
+ ErrorRecord.new(
479
+ code: "enrichment_field_not_found",
480
+ message: "Enrichment field was not found",
481
+ severity: "warning",
482
+ component: "enrichment",
483
+ details: {field: name}
484
+ )
485
+ end
486
+
487
+ def catalog_value_after_label(text, label, catalog)
488
+ match = text.match(label)
489
+ return nil unless match
490
+
491
+ remainder = text[match.end(0)..].to_s.lstrip
492
+
493
+ canonical =
494
+ catalog.values
495
+ .sort_by { |value| -value.length }
496
+ .find do |value|
497
+ remainder.match?(
498
+ /\A#{Regexp.escape(value)}(?:\s{2,}|\n|\z)/i
499
+ )
500
+ end
501
+
502
+ return nil unless canonical
503
+
504
+ raw = remainder.match(
505
+ /\A#{Regexp.escape(canonical)}/i
506
+ )[0]
507
+
508
+ field(
509
+ raw,
510
+ normalized: canonical,
511
+ method: "label_regex",
512
+ confidence: "high"
513
+ )
514
+ end
515
+ end
516
+ end
517
+ end