document_types-invoice 0.1.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/LICENSE.txt +21 -0
- data/README.md +82 -0
- data/Rakefile +6 -0
- data/app/models/document_types/invoice_record.rb +842 -0
- data/app/services/document_types/invoice_service.rb +239 -0
- data/lib/document_types/invoice/version.rb +5 -0
- data/lib/document_types/invoice.rb +33 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 2e660ac1529491297fe68d1e079e492bac8da450207ac135ad5c2ec3d9c54ea1
|
4
|
+
data.tar.gz: 53ce6371951956104ccea6fdbcfdfdf4440da1b7b93df85e66b0143632a4c2f7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 14758fa7f8e3b6b6f4a553eceda637815c1dc80344f32505fb00c49ca044ec58ee697b77ae7bd440a1d7bf71268e254a1e8e4858f383926fa493048f07e4b575
|
7
|
+
data.tar.gz: 0bda661a6725f84624ae0447327cbf686972f50e497d8a3cbd694c04f47a33007c5f5451203e3a9d2cb3a5954dfb6cea746b9d82836c199579dbfcf5870943de
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 plugandwork
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
# DocumentTypes::Invoice
|
2
|
+
|
3
|
+
A gem that adds invoice document type support to plugandwork application.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'document_types-invoice'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
## Prerequisites
|
20
|
+
|
21
|
+
This gem requires the following infrastructure in your application:
|
22
|
+
|
23
|
+
1. A `DocumentTypes::Registry` class for document type registration
|
24
|
+
2. A `DocumentTypable` concern included in your `Doc` model
|
25
|
+
3. Mongoid as the database ORM
|
26
|
+
|
27
|
+
## Features
|
28
|
+
|
29
|
+
- Invoice document type with specific fields (invoice number, dates, amounts, etc.)
|
30
|
+
- Automatic detection of invoices from PDF and XML files
|
31
|
+
- Support for structured invoice formats (Factur-X, UBL, CII)
|
32
|
+
- Invoice-specific search functionality
|
33
|
+
- Business logic methods for invoice processing
|
34
|
+
|
35
|
+
## Usage
|
36
|
+
|
37
|
+
### Search for invoices
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
# Search invoices by amount
|
41
|
+
invoices = DocumentTypes::InvoiceService.search(min_amount: 1000, max_amount: 5000)
|
42
|
+
|
43
|
+
# Get unpaid invoices
|
44
|
+
unpaid = DocumentTypes::InvoiceService.unpaid_invoices
|
45
|
+
|
46
|
+
# Get overdue invoices
|
47
|
+
overdue = DocumentTypes::InvoiceService.overdue_invoices
|
48
|
+
```
|
49
|
+
|
50
|
+
### Convert a document to an invoice
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
# Detect if a document is an invoice
|
54
|
+
if DocumentTypes::InvoiceRecord.detect(my_doc)
|
55
|
+
# Process as an invoice
|
56
|
+
invoice = my_doc.as_invoice
|
57
|
+
puts "Invoice: #{invoice.invoice_number}, Amount: #{invoice.total_amount} #{invoice.currency}"
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
### Get invoice statistics
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
# Get statistics for the current month
|
65
|
+
stats = DocumentTypes::InvoiceService.stats_for_current_month
|
66
|
+
|
67
|
+
puts "Total: #{stats[:total]}"
|
68
|
+
puts "Average: #{stats[:average]}"
|
69
|
+
puts "Count: #{stats[:count]}"
|
70
|
+
```
|
71
|
+
|
72
|
+
## Development
|
73
|
+
|
74
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
75
|
+
|
76
|
+
## Contributing
|
77
|
+
|
78
|
+
Bug reports and pull requests are welcome on GitHub.
|
79
|
+
|
80
|
+
## License
|
81
|
+
|
82
|
+
The gem is available as open source under the terms of the MIT License.
|
data/Rakefile
ADDED
@@ -0,0 +1,842 @@
|
|
1
|
+
module DocumentTypes
|
2
|
+
class InvoiceRecord
|
3
|
+
include Mongoid::Document
|
4
|
+
include Mongoid::Timestamps
|
5
|
+
|
6
|
+
# Indexation of fields for search
|
7
|
+
index({ invoice_number: 1 })
|
8
|
+
index({ issue_date: 1 })
|
9
|
+
index({ total_amount: 1 })
|
10
|
+
index({ seller_name: 1 })
|
11
|
+
index({ buyer_name: 1 })
|
12
|
+
index({ payment_status: 1 })
|
13
|
+
index({ payment_due_date: 1 })
|
14
|
+
index({ doc_id: 1 })
|
15
|
+
|
16
|
+
# Invoice-specific fields
|
17
|
+
field :invoice_number, type: String
|
18
|
+
field :issue_date, type: Date
|
19
|
+
field :document_type, type: String # Invoice type: standard, credit note, proforma...
|
20
|
+
|
21
|
+
# Seller information
|
22
|
+
field :seller_name, type: String
|
23
|
+
field :seller_tax_id, type: String
|
24
|
+
field :seller_address, type: String
|
25
|
+
|
26
|
+
# Buyer information
|
27
|
+
field :buyer_name, type: String
|
28
|
+
field :buyer_tax_id, type: String
|
29
|
+
field :buyer_address, type: String
|
30
|
+
|
31
|
+
# Amounts
|
32
|
+
field :net_amount, type: Float
|
33
|
+
field :tax_amount, type: Float
|
34
|
+
field :total_amount, type: Float
|
35
|
+
field :currency, type: String, default: "EUR"
|
36
|
+
|
37
|
+
# Electronic format
|
38
|
+
field :electronic_format, type: String # factur_x, ubl, cii, etc.
|
39
|
+
|
40
|
+
# Payment status
|
41
|
+
field :payment_status, type: String, default: "unpaid" # unpaid, partial, paid
|
42
|
+
field :payment_due_date, type: Date
|
43
|
+
field :payment_method, type: String
|
44
|
+
|
45
|
+
# Line items (stored as sub-document)
|
46
|
+
field :line_items, type: Array, default: []
|
47
|
+
|
48
|
+
# Relation to original document
|
49
|
+
belongs_to :doc, class_name: "Doc", index: true
|
50
|
+
|
51
|
+
# Validation methods
|
52
|
+
validates :doc, presence: true
|
53
|
+
validates :invoice_number, presence: true, if: -> { electronic_format.present? }
|
54
|
+
|
55
|
+
# Callbacks
|
56
|
+
after_save :update_doc
|
57
|
+
|
58
|
+
# Class methods
|
59
|
+
|
60
|
+
# Detect if a document is an invoice
|
61
|
+
def self.detect(doc)
|
62
|
+
# Check if type is already defined
|
63
|
+
return true if doc.document_type == 'invoice'
|
64
|
+
|
65
|
+
# Detection based on extension and content
|
66
|
+
doc.to_temp_file do |tf|
|
67
|
+
file_ext = doc.file_ext.to_s.downcase
|
68
|
+
file_path = tf.path
|
69
|
+
|
70
|
+
case file_ext
|
71
|
+
when 'pdf'
|
72
|
+
return detect_facturx(file_path)
|
73
|
+
when 'xml'
|
74
|
+
return detect_ubl(file_path) || detect_cii(file_path)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Detection based on existing superfields
|
79
|
+
if doc.superfields.present?
|
80
|
+
invoice_indicators = [
|
81
|
+
doc.superfields['invoice_number'],
|
82
|
+
doc.superfields['document_number'],
|
83
|
+
doc.superfields['amounts_total'],
|
84
|
+
doc.superfields['amounts_grand_total']
|
85
|
+
]
|
86
|
+
|
87
|
+
return true if invoice_indicators.compact.any?
|
88
|
+
end
|
89
|
+
|
90
|
+
# Detection based on OCR or text content (for unstructured PDFs)
|
91
|
+
if doc.text_parts.present?
|
92
|
+
invoice_keywords = %w[facture invoice rechnung factura fattura montant amount total tva vat tax client customer]
|
93
|
+
|
94
|
+
text_content = doc.text_parts.map(&:text).join(' ').downcase
|
95
|
+
matched_keywords = invoice_keywords.count { |keyword| text_content.include?(keyword) }
|
96
|
+
|
97
|
+
# If at least 3 keywords are found, consider as invoice
|
98
|
+
return true if matched_keywords >= 3
|
99
|
+
end
|
100
|
+
|
101
|
+
false
|
102
|
+
end
|
103
|
+
|
104
|
+
# Detect if a PDF is in Factur-X format
|
105
|
+
def self.detect_facturx(file_path)
|
106
|
+
begin
|
107
|
+
require 'pdf-reader'
|
108
|
+
|
109
|
+
# Method 1: Check XMP metadata
|
110
|
+
reader = PDF::Reader.new(file_path)
|
111
|
+
if reader.metadata.present?
|
112
|
+
metadata_string = reader.metadata.to_s
|
113
|
+
metadata_check = metadata_string.include?('urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#') ||
|
114
|
+
metadata_string.include?('factur-x') ||
|
115
|
+
metadata_string.include?('zugferd')
|
116
|
+
|
117
|
+
return true if metadata_check
|
118
|
+
end
|
119
|
+
|
120
|
+
# Method 2: Check embedded XML files
|
121
|
+
io = File.open(file_path, 'rb')
|
122
|
+
content = io.read
|
123
|
+
io.close
|
124
|
+
|
125
|
+
facturx_pattern = /\/EmbeddedFile\s+(.+?(factur-x\.xml|zugferd-invoice\.xml|metadata\.xml).+?)\s+endobj/im
|
126
|
+
return content.match(facturx_pattern).present?
|
127
|
+
rescue => e
|
128
|
+
Rails.logger.error "Error detecting Factur-X: #{e.message}"
|
129
|
+
return false
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# Detect if an XML is in UBL format
|
134
|
+
def self.detect_ubl(file_path)
|
135
|
+
begin
|
136
|
+
require 'nokogiri'
|
137
|
+
|
138
|
+
xml = File.read(file_path)
|
139
|
+
doc = Nokogiri::XML(xml)
|
140
|
+
|
141
|
+
# Check UBL namespaces
|
142
|
+
ubl_ns = %w[urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2]
|
143
|
+
|
144
|
+
ubl_ns.each do |ns|
|
145
|
+
return true if doc.xpath("//*[namespace-uri()='#{ns}']").any?
|
146
|
+
end
|
147
|
+
|
148
|
+
false
|
149
|
+
rescue => e
|
150
|
+
Rails.logger.error "Error detecting UBL: #{e.message}"
|
151
|
+
false
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Detect if an XML is in CII format
|
156
|
+
def self.detect_cii(file_path)
|
157
|
+
begin
|
158
|
+
|
159
|
+
xml = File.read(file_path)
|
160
|
+
doc = Nokogiri::XML(xml)
|
161
|
+
|
162
|
+
# Check CII namespaces
|
163
|
+
cii_ns = %w[urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100 urn:un:unece:uncefact:data:standard:CrossIndustryDocument:invoice:1p0#]
|
164
|
+
|
165
|
+
cii_ns.each do |ns|
|
166
|
+
return true if doc.xpath("//*[namespace-uri()='#{ns}']").any?
|
167
|
+
end
|
168
|
+
|
169
|
+
false
|
170
|
+
rescue => e
|
171
|
+
Rails.logger.error "Error detecting CII: #{e.message}"
|
172
|
+
false
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Instance methods
|
177
|
+
|
178
|
+
# Synchronize with the original document
|
179
|
+
def sync_with_doc
|
180
|
+
# Extract and convert data from the document
|
181
|
+
doc.to_temp_file do |tf|
|
182
|
+
file_path = tf.path
|
183
|
+
|
184
|
+
# Based on detected format
|
185
|
+
case doc.file_ext.to_s.downcase
|
186
|
+
when 'pdf'
|
187
|
+
if self.class.detect_facturx(file_path)
|
188
|
+
self.electronic_format = 'factur_x'
|
189
|
+
extract_facturx_data(file_path)
|
190
|
+
else
|
191
|
+
# For unstructured PDFs, try to extract via OCR/text analysis
|
192
|
+
extract_from_text
|
193
|
+
end
|
194
|
+
when 'xml'
|
195
|
+
if self.class.detect_ubl(file_path)
|
196
|
+
self.electronic_format = 'ubl'
|
197
|
+
extract_ubl_data(file_path)
|
198
|
+
elsif self.class.detect_cii(file_path)
|
199
|
+
self.electronic_format = 'cii'
|
200
|
+
extract_cii_data(file_path)
|
201
|
+
end
|
202
|
+
else
|
203
|
+
# For other formats, try to extract from superfields
|
204
|
+
extract_from_superfields
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Save after synchronization
|
209
|
+
save
|
210
|
+
end
|
211
|
+
|
212
|
+
# Update the original document
|
213
|
+
def update_doc
|
214
|
+
# Update superfields in the original document
|
215
|
+
sf = doc.superfields || {}
|
216
|
+
|
217
|
+
# Update key fields in superfields
|
218
|
+
sf['invoice_number'] = self.invoice_number
|
219
|
+
sf['issue_date'] = self.issue_date&.iso8601
|
220
|
+
sf['seller_name'] = self.seller_name
|
221
|
+
sf['buyer_name'] = self.buyer_name
|
222
|
+
sf['total_amount'] = self.total_amount
|
223
|
+
sf['currency'] = self.currency
|
224
|
+
|
225
|
+
# Update the document
|
226
|
+
doc.superfields = sf
|
227
|
+
doc.save
|
228
|
+
end
|
229
|
+
|
230
|
+
# Extract data from Factur-X format
|
231
|
+
def extract_facturx_data(file_path)
|
232
|
+
begin
|
233
|
+
# Read PDF content
|
234
|
+
io = File.open(file_path, 'rb')
|
235
|
+
content = io.read
|
236
|
+
io.close
|
237
|
+
|
238
|
+
# Look for embedded XML files
|
239
|
+
facturx_xml = nil
|
240
|
+
content.scan(/\/EmbeddedFile\s+(.+?)\s+endobj/m) do |match|
|
241
|
+
attachment = match[0]
|
242
|
+
if attachment.include?('factur-x.xml') ||
|
243
|
+
attachment.include?('zugferd-invoice.xml') ||
|
244
|
+
attachment.include?('metadata.xml')
|
245
|
+
|
246
|
+
xml_match = attachment.match(/stream\s+(.*?)\s+endstream/m)
|
247
|
+
if xml_match
|
248
|
+
facturx_xml = xml_match[1]
|
249
|
+
break
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
return unless facturx_xml
|
255
|
+
|
256
|
+
# Parse XML content
|
257
|
+
doc = Nokogiri::XML(facturx_xml)
|
258
|
+
|
259
|
+
# Add namespaces for easier navigation
|
260
|
+
namespaces = {
|
261
|
+
'fx' => 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#',
|
262
|
+
'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
|
263
|
+
'rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
|
264
|
+
'udt' => 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'
|
265
|
+
}
|
266
|
+
|
267
|
+
namespaces.each do |prefix, uri|
|
268
|
+
doc.root.add_namespace(prefix, uri) if doc.root
|
269
|
+
end
|
270
|
+
|
271
|
+
# Extract invoice data
|
272
|
+
self.document_type = extract_text(doc, '//rsm:ExchangedDocument/ram:TypeCode')
|
273
|
+
self.invoice_number = extract_text(doc, '//rsm:ExchangedDocument/ram:ID')
|
274
|
+
|
275
|
+
# Extract date
|
276
|
+
date_text = extract_text(doc, '//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString')
|
277
|
+
if date_text.present?
|
278
|
+
# Handle different date formats (YYYYMMDD or ISO)
|
279
|
+
if date_text.match(/^\d{8}$/)
|
280
|
+
self.issue_date = Date.parse("#{date_text[0..3]}-#{date_text[4..5]}-#{date_text[6..7]}")
|
281
|
+
else
|
282
|
+
self.issue_date = Date.parse(date_text)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# Extract seller info
|
287
|
+
self.seller_name = extract_text(doc, '//ram:SellerTradeParty/ram:Name')
|
288
|
+
self.seller_tax_id = extract_text(doc, '//ram:SellerTradeParty//ram:ID[../ram:TypeCode="VA"]')
|
289
|
+
|
290
|
+
# Extract buyer info
|
291
|
+
self.buyer_name = extract_text(doc, '//ram:BuyerTradeParty/ram:Name')
|
292
|
+
self.buyer_tax_id = extract_text(doc, '//ram:BuyerTradeParty//ram:ID[../ram:TypeCode="VA"]')
|
293
|
+
|
294
|
+
# Extract amounts
|
295
|
+
self.net_amount = extract_text(doc, '//ram:LineTotalAmount').to_f
|
296
|
+
self.tax_amount = extract_text(doc, '//ram:TaxTotalAmount').to_f
|
297
|
+
self.total_amount = extract_text(doc, '//ram:GrandTotalAmount').to_f
|
298
|
+
self.currency = extract_text(doc, '//ram:InvoiceCurrencyCode') || 'EUR'
|
299
|
+
|
300
|
+
# Extract payment info
|
301
|
+
due_date_text = extract_text(doc, '//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString')
|
302
|
+
if due_date_text.present?
|
303
|
+
if due_date_text.match(/^\d{8}$/)
|
304
|
+
self.payment_due_date = Date.parse("#{due_date_text[0..3]}-#{due_date_text[4..5]}-#{due_date_text[6..7]}")
|
305
|
+
else
|
306
|
+
self.payment_due_date = Date.parse(due_date_text)
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
# Extract line items if present
|
311
|
+
line_items = []
|
312
|
+
doc.xpath('//ram:IncludedSupplyChainTradeLineItem').each do |line_node|
|
313
|
+
item = {
|
314
|
+
description: extract_text(line_node, './/ram:Name'),
|
315
|
+
quantity: extract_text(line_node, './/ram:BilledQuantity').to_f,
|
316
|
+
unit_price: extract_text(line_node, './/ram:ChargeAmount').to_f,
|
317
|
+
line_total: extract_text(line_node, './/ram:LineTotalAmount').to_f
|
318
|
+
}
|
319
|
+
line_items << item if item[:description].present?
|
320
|
+
end
|
321
|
+
|
322
|
+
self.line_items = line_items if line_items.any?
|
323
|
+
|
324
|
+
rescue => e
|
325
|
+
Rails.logger.error "Error extracting Factur-X data: #{e.message}"
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
# Extract data from UBL format
|
330
|
+
def extract_ubl_data(file_path)
|
331
|
+
begin
|
332
|
+
xml = File.read(file_path)
|
333
|
+
doc = Nokogiri::XML(xml)
|
334
|
+
|
335
|
+
# Add UBL namespaces
|
336
|
+
namespaces = {
|
337
|
+
'ubl' => 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
338
|
+
'cac' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
|
339
|
+
'cbc' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'
|
340
|
+
}
|
341
|
+
|
342
|
+
# Extract invoice data
|
343
|
+
self.document_type = extract_text(doc, '//cbc:InvoiceTypeCode', namespaces)
|
344
|
+
self.invoice_number = extract_text(doc, '//cbc:ID', namespaces)
|
345
|
+
|
346
|
+
# Extract date
|
347
|
+
date_text = extract_text(doc, '//cbc:IssueDate', namespaces)
|
348
|
+
self.issue_date = Date.parse(date_text) if date_text.present?
|
349
|
+
|
350
|
+
# Extract seller info
|
351
|
+
self.seller_name = extract_text(doc, '//cac:AccountingSupplierParty//cac:PartyName/cbc:Name', namespaces)
|
352
|
+
self.seller_tax_id = extract_text(doc, '//cac:AccountingSupplierParty//cac:PartyTaxScheme/cbc:CompanyID', namespaces)
|
353
|
+
|
354
|
+
# Extract buyer info
|
355
|
+
self.buyer_name = extract_text(doc, '//cac:AccountingCustomerParty//cac:PartyName/cbc:Name', namespaces)
|
356
|
+
self.buyer_tax_id = extract_text(doc, '//cac:AccountingCustomerParty//cac:PartyTaxScheme/cbc:CompanyID', namespaces)
|
357
|
+
|
358
|
+
# Extract amounts
|
359
|
+
self.net_amount = extract_text(doc, '//cac:LegalMonetaryTotal/cbc:LineExtensionAmount', namespaces).to_f
|
360
|
+
self.tax_amount = extract_text(doc, '//cac:TaxTotal/cbc:TaxAmount', namespaces).to_f
|
361
|
+
self.total_amount = extract_text(doc, '//cac:LegalMonetaryTotal/cbc:PayableAmount', namespaces).to_f
|
362
|
+
self.currency = extract_text(doc, '//cbc:DocumentCurrencyCode', namespaces) || 'EUR'
|
363
|
+
|
364
|
+
# Extract payment info
|
365
|
+
due_date_text = extract_text(doc, '//cac:PaymentTerms/cbc:PaymentDueDate', namespaces)
|
366
|
+
self.payment_due_date = Date.parse(due_date_text) if due_date_text.present?
|
367
|
+
|
368
|
+
# Extract line items
|
369
|
+
line_items = []
|
370
|
+
doc.xpath('//cac:InvoiceLine', namespaces).each do |line_node|
|
371
|
+
item = {
|
372
|
+
description: extract_text(line_node, './cac:Item/cbc:Description', namespaces),
|
373
|
+
quantity: extract_text(line_node, './cbc:InvoicedQuantity', namespaces).to_f,
|
374
|
+
unit_price: extract_text(line_node, './cac:Price/cbc:PriceAmount', namespaces).to_f,
|
375
|
+
line_total: extract_text(line_node, './cbc:LineExtensionAmount', namespaces).to_f
|
376
|
+
}
|
377
|
+
line_items << item if item[:description].present?
|
378
|
+
end
|
379
|
+
|
380
|
+
self.line_items = line_items if line_items.any?
|
381
|
+
|
382
|
+
rescue => e
|
383
|
+
Rails.logger.error "Error extracting UBL data: #{e.message}"
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
# Extract data from CII format
|
388
|
+
def extract_cii_data(file_path)
|
389
|
+
begin
|
390
|
+
require 'nokogiri'
|
391
|
+
|
392
|
+
xml = File.read(file_path)
|
393
|
+
doc = Nokogiri::XML(xml)
|
394
|
+
|
395
|
+
# Add CII namespaces
|
396
|
+
namespaces = {
|
397
|
+
'rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
|
398
|
+
'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'
|
399
|
+
}
|
400
|
+
|
401
|
+
# Similar extraction logic as Factur-X but with different XPaths
|
402
|
+
# ...
|
403
|
+
|
404
|
+
rescue => e
|
405
|
+
Rails.logger.error "Error extracting CII data: #{e.message}"
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
# Extract invoice data from document text using pattern matching
|
410
|
+
def extract_from_text
|
411
|
+
return unless doc.text_parts.present?
|
412
|
+
|
413
|
+
text = doc.text_parts.map(&:text).join(' ')
|
414
|
+
|
415
|
+
# Try to extract invoice number
|
416
|
+
invoice_number_patterns = [
|
417
|
+
/facture\s+(?:n[°o]|num[ée]ro)\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i,
|
418
|
+
/invoice\s+(?:no|number)\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i,
|
419
|
+
/num[ée]ro\s+de\s+facture\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i,
|
420
|
+
/rechnung\s+(?:nr|nummer)\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i
|
421
|
+
]
|
422
|
+
|
423
|
+
invoice_number_patterns.each do |pattern|
|
424
|
+
if (match = text.match(pattern))
|
425
|
+
self.invoice_number = match[1].strip
|
426
|
+
break
|
427
|
+
end
|
428
|
+
end
|
429
|
+
|
430
|
+
# Try to extract date
|
431
|
+
date_patterns = [
|
432
|
+
/date\s+(?:de\s+)?facture\s*[:.: ]*\s*(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/i,
|
433
|
+
/invoice\s+date\s*[:.: ]*\s*(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/i,
|
434
|
+
/date\s*[:.: ]*\s*(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/i
|
435
|
+
]
|
436
|
+
|
437
|
+
date_patterns.each do |pattern|
|
438
|
+
if (match = text.match(pattern))
|
439
|
+
begin
|
440
|
+
self.issue_date = Date.parse(match[1])
|
441
|
+
break
|
442
|
+
rescue
|
443
|
+
# Continue if date parsing fails
|
444
|
+
end
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
448
|
+
# Try to extract seller and buyer
|
449
|
+
seller_patterns = [
|
450
|
+
/vendeur\s*[:.: ]*\s*([^\]+)/i,
|
451
|
+
/seller\s*[:.: ]*\s*([^\]+)/i,
|
452
|
+
/fournisseur\s*[:.: ]*\s*([^\]+)/i,
|
453
|
+
/supplier\s*[:.: ]*\s*([^\]+)/i
|
454
|
+
]
|
455
|
+
|
456
|
+
buyer_patterns = [
|
457
|
+
/acheteur\s*[:.: ]*\\s*([^\]+)/i,
|
458
|
+
/buyer\s*[:.: ]*\\s*([^\]+)/i,
|
459
|
+
/client\s*[:.: ]*\\s*([^\]+)/i,
|
460
|
+
/customer\s*[:.: ]*\\s*([^\]+)/i
|
461
|
+
]
|
462
|
+
|
463
|
+
seller_patterns.each do |pattern|
|
464
|
+
if (match = text.match(pattern))
|
465
|
+
self.seller_name = match[1].strip
|
466
|
+
break
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
buyer_patterns.each do |pattern|
|
471
|
+
if (match = text.match(pattern))
|
472
|
+
self.buyer_name = match[1].strip
|
473
|
+
break
|
474
|
+
end
|
475
|
+
end
|
476
|
+
|
477
|
+
# Try to extract amount
|
478
|
+
amount_patterns = [
|
479
|
+
/montant\s+(?:total|ttc)\s*[:.: ]*\s*(\d+[\s,.]\d+)/i,
|
480
|
+
/total\s+amount\s*[:.: ]*\s*(\d+[\s,.]\d+)/i,
|
481
|
+
/total\s+(?:ttc|tva incluse)\s*[:.: ]*\s*(\d+[\s,.]\d+)/i,
|
482
|
+
/total\s+(?:ht)\s*[:.: ]*\s*(\d+[\s,.]\d+)/i
|
483
|
+
]
|
484
|
+
|
485
|
+
amount_patterns.each do |pattern|
|
486
|
+
if (match = text.match(pattern))
|
487
|
+
amount_str = match[1].strip.gsub(/\s/, '').gsub(',', '.')
|
488
|
+
self.total_amount = amount_str.to_f
|
489
|
+
break
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
# Try to extract currency
|
494
|
+
currency_patterns = [
|
495
|
+
/([€$£])/,
|
496
|
+
/\b(EUR|USD|GBP)\b/i
|
497
|
+
]
|
498
|
+
|
499
|
+
currency_mapping = {
|
500
|
+
'€' => 'EUR',
|
501
|
+
'$' => 'USD',
|
502
|
+
'£' => 'GBP'
|
503
|
+
}
|
504
|
+
|
505
|
+
currency_patterns.each do |pattern|
|
506
|
+
if (match = text.match(pattern))
|
507
|
+
symbol = match[1].strip
|
508
|
+
self.currency = currency_mapping[symbol] || symbol
|
509
|
+
break
|
510
|
+
end
|
511
|
+
end
|
512
|
+
end
|
513
|
+
|
514
|
+
# Extract invoice data from document superfields
|
515
|
+
def extract_from_superfields
|
516
|
+
return unless doc.superfields.present?
|
517
|
+
|
518
|
+
# Map superfields to invoice record fields
|
519
|
+
field_mappings = {
|
520
|
+
'invoice_number' => :invoice_number,
|
521
|
+
'document_number' => :invoice_number,
|
522
|
+
'issue_date' => :issue_date,
|
523
|
+
'seller_name' => :seller_name,
|
524
|
+
'buyer_name' => :buyer_name,
|
525
|
+
'amounts_net' => :net_amount,
|
526
|
+
'amounts_tax' => :tax_amount,
|
527
|
+
'amounts_grand_total' => :total_amount,
|
528
|
+
'amounts_total' => :total_amount,
|
529
|
+
'currency' => :currency
|
530
|
+
}
|
531
|
+
|
532
|
+
field_mappings.each do |sf_key, record_field|
|
533
|
+
if doc.superfields[sf_key].present?
|
534
|
+
value = doc.superfields[sf_key]
|
535
|
+
|
536
|
+
# Convert to appropriate type
|
537
|
+
case record_field
|
538
|
+
when :issue_date
|
539
|
+
self[record_field] = value.is_a?(Date) ? value : Date.parse(value)
|
540
|
+
when :net_amount, :tax_amount, :total_amount
|
541
|
+
self[record_field] = value.to_f
|
542
|
+
else
|
543
|
+
self[record_field] = value
|
544
|
+
end
|
545
|
+
end
|
546
|
+
end
|
547
|
+
end
|
548
|
+
|
549
|
+
# Helper method to extract text from XML node
|
550
|
+
def extract_text(doc, xpath, namespaces = nil)
|
551
|
+
node = namespaces ? doc.at_xpath(xpath, namespaces) : doc.at_xpath(xpath)
|
552
|
+
node ? node.text.strip : nil
|
553
|
+
end
|
554
|
+
|
555
|
+
# Business logic methods
|
556
|
+
|
557
|
+
# Calculate tax rate
|
558
|
+
def tax_rate
|
559
|
+
return nil if net_amount.blank? || net_amount.zero? || tax_amount.blank?
|
560
|
+
(tax_amount / net_amount * 100).round(2)
|
561
|
+
end
|
562
|
+
|
563
|
+
# Check if the invoice is paid
|
564
|
+
def paid?
|
565
|
+
payment_status == 'paid'
|
566
|
+
end
|
567
|
+
|
568
|
+
# Check if the invoice is partially paid
|
569
|
+
def partially_paid?
|
570
|
+
payment_status == 'partial'
|
571
|
+
end
|
572
|
+
|
573
|
+
# Check if the invoice has payment due
|
574
|
+
def payment_due?
|
575
|
+
!paid? && payment_due_date.present? && payment_due_date < Date.today
|
576
|
+
end
|
577
|
+
|
578
|
+
# Get days until payment is due
|
579
|
+
def days_until_due
|
580
|
+
return nil unless payment_due_date
|
581
|
+
(payment_due_date - Date.today).to_i
|
582
|
+
end
|
583
|
+
|
584
|
+
# Get seller's formatted address
|
585
|
+
def formatted_seller_address
|
586
|
+
seller_address.present? ? seller_address : "Address not available"
|
587
|
+
end
|
588
|
+
|
589
|
+
# Get buyer's formatted address
|
590
|
+
def formatted_buyer_address
|
591
|
+
buyer_address.present? ? buyer_address : "Address not available"
|
592
|
+
end
|
593
|
+
|
594
|
+
# Format amount with currency
|
595
|
+
def formatted_total_amount
|
596
|
+
"#{total_amount} #{currency}"
|
597
|
+
end
|
598
|
+
|
599
|
+
# Get invoice type description
|
600
|
+
def document_type_description
|
601
|
+
case document_type
|
602
|
+
when '380'
|
603
|
+
'Commercial Invoice'
|
604
|
+
when '381'
|
605
|
+
'Credit Note'
|
606
|
+
when '383'
|
607
|
+
'Debit Note'
|
608
|
+
when '386'
|
609
|
+
'Prepayment Invoice'
|
610
|
+
when '389'
|
611
|
+
'Self-Billed Invoice'
|
612
|
+
else
|
613
|
+
document_type || 'Invoice'
|
614
|
+
end
|
615
|
+
end
|
616
|
+
end
|
617
|
+
end
|
618
|
+
::XML(xml)
|
619
|
+
|
620
|
+
# Add CII namespaces
|
621
|
+
namespaces = {
|
622
|
+
'rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
|
623
|
+
'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'
|
624
|
+
}
|
625
|
+
|
626
|
+
# Similar extraction logic as Factur-X but with different XPaths
|
627
|
+
# ...
|
628
|
+
|
629
|
+
rescue => e
|
630
|
+
Rails.logger.error "Error extracting CII data: #{e.message}"
|
631
|
+
end
|
632
|
+
end
|
633
|
+
|
634
|
+
# Extract invoice data from document text using pattern matching
|
635
|
+
def extract_from_text
|
636
|
+
return unless doc.text_parts.present?
|
637
|
+
|
638
|
+
text = doc.text_parts.map(&:text).join(' ')
|
639
|
+
|
640
|
+
# Try to extract invoice number
|
641
|
+
invoice_number_patterns = [
|
642
|
+
/facture\s+(?:n[°o]|num[ée]ro)\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i,
|
643
|
+
/invoice\s+(?:no|number)\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i,
|
644
|
+
/num[ée]ro\s+de\s+facture\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i,
|
645
|
+
/rechnung\s+(?:nr|nummer)\s*[:.: ]*\s*([A-Z0-9\-_\/]+)/i
|
646
|
+
]
|
647
|
+
|
648
|
+
invoice_number_patterns.each do |pattern|
|
649
|
+
if (match = text.match(pattern))
|
650
|
+
self.invoice_number = match[1].strip
|
651
|
+
break
|
652
|
+
end
|
653
|
+
end
|
654
|
+
|
655
|
+
# Try to extract date
|
656
|
+
date_patterns = [
|
657
|
+
/date\s+(?:de\s+)?facture\s*[:.: ]*\s*(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/i,
|
658
|
+
/invoice\s+date\s*[:.: ]*\s*(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/i,
|
659
|
+
/date\s*[:.: ]*\s*(\d{1,2}[\/-]\d{1,2}[\/-]\d{2,4})/i
|
660
|
+
]
|
661
|
+
|
662
|
+
date_patterns.each do |pattern|
|
663
|
+
if (match = text.match(pattern))
|
664
|
+
begin
|
665
|
+
self.issue_date = Date.parse(match[1])
|
666
|
+
break
|
667
|
+
rescue
|
668
|
+
# Continue if date parsing fails
|
669
|
+
end
|
670
|
+
end
|
671
|
+
end
|
672
|
+
|
673
|
+
# Try to extract seller and buyer
|
674
|
+
seller_patterns = [
|
675
|
+
/vendeur\s*[:.: ]*\s*([^\n]+)/i,
|
676
|
+
/seller\s*[:.: ]*\s*([^\n]+)/i,
|
677
|
+
/fournisseur\s*[:.: ]*\s*([^\n]+)/i,
|
678
|
+
/supplier\s*[:.: ]*\s*([^\n]+)/i
|
679
|
+
]
|
680
|
+
|
681
|
+
buyer_patterns = [
|
682
|
+
/acheteur\s*[:.: ]*\s*([^\n]+)/i,
|
683
|
+
/buyer\s*[:.: ]*\s*([^\n]+)/i,
|
684
|
+
/client\s*[:.: ]*\s*([^\n]+)/i,
|
685
|
+
/customer\s*[:.: ]*\s*([^\n]+)/i
|
686
|
+
]
|
687
|
+
|
688
|
+
seller_patterns.each do |pattern|
|
689
|
+
if (match = text.match(pattern))
|
690
|
+
self.seller_name = match[1].strip
|
691
|
+
break
|
692
|
+
end
|
693
|
+
end
|
694
|
+
|
695
|
+
buyer_patterns.each do |pattern|
|
696
|
+
if (match = text.match(pattern))
|
697
|
+
self.buyer_name = match[1].strip
|
698
|
+
break
|
699
|
+
end
|
700
|
+
end
|
701
|
+
|
702
|
+
# Try to extract amount
|
703
|
+
amount_patterns = [
|
704
|
+
/montant\s+(?:total|ttc)\s*[:.: ]*\s*(\d+[\s,.]\d+)/i,
|
705
|
+
/total\s+amount\s*[:.: ]*\s*(\d+[\s,.]\d+)/i,
|
706
|
+
/total\s+(?:ttc|tva incluse)\s*[:.: ]*\s*(\d+[\s,.]\d+)/i,
|
707
|
+
/total\s+(?:ht)\s*[:.: ]*\s*(\d+[\s,.]\d+)/i
|
708
|
+
]
|
709
|
+
|
710
|
+
amount_patterns.each do |pattern|
|
711
|
+
if (match = text.match(pattern))
|
712
|
+
amount_str = match[1].strip.gsub(/\s/, '').gsub(',', '.')
|
713
|
+
self.total_amount = amount_str.to_f
|
714
|
+
break
|
715
|
+
end
|
716
|
+
end
|
717
|
+
|
718
|
+
# Try to extract currency
|
719
|
+
currency_patterns = [
|
720
|
+
/([€$£])/,
|
721
|
+
/\b(EUR|USD|GBP)\b/i
|
722
|
+
]
|
723
|
+
|
724
|
+
currency_mapping = {
|
725
|
+
'€' => 'EUR',
|
726
|
+
'$' => 'USD',
|
727
|
+
'£' => 'GBP'
|
728
|
+
}
|
729
|
+
|
730
|
+
currency_patterns.each do |pattern|
|
731
|
+
if (match = text.match(pattern))
|
732
|
+
symbol = match[1].strip
|
733
|
+
self.currency = currency_mapping[symbol] || symbol
|
734
|
+
break
|
735
|
+
end
|
736
|
+
end
|
737
|
+
end
|
738
|
+
|
739
|
+
# Extract invoice data from document superfields
|
740
|
+
def extract_from_superfields
|
741
|
+
return unless doc.superfields.present?
|
742
|
+
|
743
|
+
# Map superfields to invoice record fields
|
744
|
+
field_mappings = {
|
745
|
+
'invoice_number' => :invoice_number,
|
746
|
+
'document_number' => :invoice_number,
|
747
|
+
'issue_date' => :issue_date,
|
748
|
+
'seller_name' => :seller_name,
|
749
|
+
'buyer_name' => :buyer_name,
|
750
|
+
'amounts_net' => :net_amount,
|
751
|
+
'amounts_tax' => :tax_amount,
|
752
|
+
'amounts_grand_total' => :total_amount,
|
753
|
+
'amounts_total' => :total_amount,
|
754
|
+
'currency' => :currency
|
755
|
+
}
|
756
|
+
|
757
|
+
field_mappings.each do |sf_key, record_field|
|
758
|
+
if doc.superfields[sf_key].present?
|
759
|
+
value = doc.superfields[sf_key]
|
760
|
+
|
761
|
+
# Convert to appropriate type
|
762
|
+
case record_field
|
763
|
+
when :issue_date
|
764
|
+
self[record_field] = value.is_a?(Date) ? value : Date.parse(value) rescue nil
|
765
|
+
when :net_amount, :tax_amount, :total_amount
|
766
|
+
self[record_field] = value.to_f
|
767
|
+
else
|
768
|
+
self[record_field] = value
|
769
|
+
end
|
770
|
+
end
|
771
|
+
end
|
772
|
+
end
|
773
|
+
|
774
|
+
# Helper method to extract text from XML node
|
775
|
+
def extract_text(doc, xpath, namespaces = nil)
|
776
|
+
node = namespaces ? doc.at_xpath(xpath, namespaces) : doc.at_xpath(xpath)
|
777
|
+
node ? node.text.strip : nil
|
778
|
+
end
|
779
|
+
|
780
|
+
# Business logic methods
|
781
|
+
|
782
|
+
# Calculate tax rate
|
783
|
+
def tax_rate
|
784
|
+
return nil if net_amount.blank? || net_amount.zero? || tax_amount.blank?
|
785
|
+
(tax_amount / net_amount * 100).round(2)
|
786
|
+
end
|
787
|
+
|
788
|
+
# Check if the invoice is paid
|
789
|
+
def paid?
|
790
|
+
payment_status == 'paid'
|
791
|
+
end
|
792
|
+
|
793
|
+
# Check if the invoice is partially paid
|
794
|
+
def partially_paid?
|
795
|
+
payment_status == 'partial'
|
796
|
+
end
|
797
|
+
|
798
|
+
# Check if the invoice has payment due
|
799
|
+
def payment_due?
|
800
|
+
!paid? && payment_due_date.present? && payment_due_date < Date.today
|
801
|
+
end
|
802
|
+
|
803
|
+
# Get days until payment is due
|
804
|
+
def days_until_due
|
805
|
+
return nil unless payment_due_date
|
806
|
+
(payment_due_date - Date.today).to_i
|
807
|
+
end
|
808
|
+
|
809
|
+
# Get seller's formatted address
|
810
|
+
def formatted_seller_address
|
811
|
+
seller_address.present? ? seller_address : "Address not available"
|
812
|
+
end
|
813
|
+
|
814
|
+
# Get buyer's formatted address
|
815
|
+
def formatted_buyer_address
|
816
|
+
buyer_address.present? ? buyer_address : "Address not available"
|
817
|
+
end
|
818
|
+
|
819
|
+
# Format amount with currency
|
820
|
+
def formatted_total_amount
|
821
|
+
"#{total_amount} #{currency}"
|
822
|
+
end
|
823
|
+
|
824
|
+
# Get invoice type description
|
825
|
+
def document_type_description
|
826
|
+
case document_type
|
827
|
+
when '380'
|
828
|
+
'Commercial Invoice'
|
829
|
+
when '381'
|
830
|
+
'Credit Note'
|
831
|
+
when '383'
|
832
|
+
'Debit Note'
|
833
|
+
when '386'
|
834
|
+
'Prepayment Invoice'
|
835
|
+
when '389'
|
836
|
+
'Self-Billed Invoice'
|
837
|
+
else
|
838
|
+
document_type || 'Invoice'
|
839
|
+
end
|
840
|
+
end
|
841
|
+
end
|
842
|
+
end
|
@@ -0,0 +1,239 @@
|
|
1
|
+
module DocumentTypes
|
2
|
+
class InvoiceService
|
3
|
+
def initialize(doc)
|
4
|
+
@doc = doc
|
5
|
+
end
|
6
|
+
|
7
|
+
# Process document as an invoice
|
8
|
+
def process
|
9
|
+
# Make sure document is an invoice type
|
10
|
+
return false unless @doc.document_type == 'invoice' || DocumentTypes::InvoiceRecord.detect(@doc)
|
11
|
+
|
12
|
+
# Create or update invoice record
|
13
|
+
invoice = @doc.as_invoice
|
14
|
+
invoice.sync_with_doc
|
15
|
+
|
16
|
+
true
|
17
|
+
end
|
18
|
+
|
19
|
+
# Search for invoices based on criteria
|
20
|
+
def self.search(criteria = {})
|
21
|
+
query = DocumentTypes::InvoiceRecord.all
|
22
|
+
|
23
|
+
# Filter by amount
|
24
|
+
if criteria[:min_amount].present?
|
25
|
+
query = query.where(:total_amount.gte => criteria[:min_amount].to_f)
|
26
|
+
end
|
27
|
+
|
28
|
+
if criteria[:max_amount].present?
|
29
|
+
query = query.where(:total_amount.lte => criteria[:max_amount].to_f)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Filter by date
|
33
|
+
if criteria[:start_date].present?
|
34
|
+
start_date = criteria[:start_date].is_a?(Date) ? criteria[:start_date] : Date.parse(criteria[:start_date].to_s)
|
35
|
+
query = query.where(:issue_date.gte => start_date)
|
36
|
+
end
|
37
|
+
|
38
|
+
if criteria[:end_date].present?
|
39
|
+
end_date = criteria[:end_date].is_a?(Date) ? criteria[:end_date] : Date.parse(criteria[:end_date].to_s)
|
40
|
+
query = query.where(:issue_date.lte => end_date)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Filter by invoice number
|
44
|
+
if criteria[:invoice_number].present?
|
45
|
+
query = query.where(invoice_number: /#{Regexp.escape(criteria[:invoice_number].to_s)}/i)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Filter by seller name
|
49
|
+
if criteria[:seller_name].present?
|
50
|
+
query = query.where(seller_name: /#{Regexp.escape(criteria[:seller_name].to_s)}/i)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Filter by buyer name
|
54
|
+
if criteria[:buyer_name].present?
|
55
|
+
query = query.where(buyer_name: /#{Regexp.escape(criteria[:buyer_name].to_s)}/i)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Filter by net amount
|
59
|
+
if criteria[:net_amount].present?
|
60
|
+
query = query.where(net_amount: criteria[:net_amount].to_f)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Filter by tax amount
|
64
|
+
if criteria[:tax_amount].present?
|
65
|
+
query = query.where(tax_amount: criteria[:tax_amount].to_f)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Filter by currency
|
69
|
+
if criteria[:currency].present?
|
70
|
+
query = query.where(currency: criteria[:currency].to_s.upcase)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Filter by payment status
|
74
|
+
if criteria[:payment_status].present?
|
75
|
+
query = query.where(payment_status: criteria[:payment_status])
|
76
|
+
end
|
77
|
+
|
78
|
+
# Filter by electronic format
|
79
|
+
if criteria[:electronic_format].present?
|
80
|
+
query = query.where(electronic_format: criteria[:electronic_format])
|
81
|
+
end
|
82
|
+
|
83
|
+
# Return query for additional chaining
|
84
|
+
query
|
85
|
+
end
|
86
|
+
|
87
|
+
# Generate reports based on invoices
|
88
|
+
def self.generate_report(criteria = {})
|
89
|
+
invoices = search(criteria)
|
90
|
+
|
91
|
+
# Calculate statistics
|
92
|
+
total = invoices.sum(:total_amount)
|
93
|
+
average = invoices.avg(:total_amount)
|
94
|
+
count = invoices.count
|
95
|
+
|
96
|
+
# Group by month
|
97
|
+
by_month = invoices.group_by { |inv| inv.issue_date&.beginning_of_month }
|
98
|
+
.transform_values { |invs| invs.sum(&:total_amount) }
|
99
|
+
|
100
|
+
# Group by seller
|
101
|
+
by_seller = invoices.group_by { |inv| inv.seller_name }
|
102
|
+
.transform_values { |invs| invs.sum(&:total_amount) }
|
103
|
+
|
104
|
+
# Group by buyer
|
105
|
+
by_buyer = invoices.group_by { |inv| inv.buyer_name }
|
106
|
+
.transform_values { |invs| invs.sum(&:total_amount) }
|
107
|
+
|
108
|
+
# Distribution by payment status
|
109
|
+
by_status = invoices.group_by { |inv| inv.payment_status }
|
110
|
+
.transform_values { |invs| invs.count }
|
111
|
+
|
112
|
+
# Return report data
|
113
|
+
{
|
114
|
+
total: total,
|
115
|
+
average: average,
|
116
|
+
count: count,
|
117
|
+
by_month: by_month,
|
118
|
+
by_seller: by_seller,
|
119
|
+
by_buyer: by_buyer,
|
120
|
+
by_status: by_status
|
121
|
+
}
|
122
|
+
end
|
123
|
+
|
124
|
+
# Get unpaid invoices
|
125
|
+
def self.unpaid_invoices(user = nil)
|
126
|
+
query = search(payment_status: 'unpaid')
|
127
|
+
|
128
|
+
# Filter by invoices accessible to user if specified
|
129
|
+
if user && user.respond_to?(:readable_doc_ids)
|
130
|
+
doc_ids = user.readable_doc_ids
|
131
|
+
query = query.where(:doc_id.in => doc_ids)
|
132
|
+
end
|
133
|
+
|
134
|
+
query
|
135
|
+
end
|
136
|
+
|
137
|
+
# Get overdue invoices
|
138
|
+
def self.overdue_invoices(user = nil)
|
139
|
+
today = Date.today
|
140
|
+
query = unpaid_invoices(user).where(:payment_due_date.lt => today)
|
141
|
+
query
|
142
|
+
end
|
143
|
+
|
144
|
+
# Get upcoming invoices
|
145
|
+
def self.upcoming_invoices(days = 7, user = nil)
|
146
|
+
today = Date.today
|
147
|
+
deadline = today + days.days
|
148
|
+
query = unpaid_invoices(user).where(:payment_due_date.gte => today, :payment_due_date.lte => deadline)
|
149
|
+
query
|
150
|
+
end
|
151
|
+
|
152
|
+
# Get total by party (seller/buyer)
|
153
|
+
def self.total_by_party(party_type, name, criteria = {})
|
154
|
+
criteria[:"#{party_type}_name"] = name
|
155
|
+
invoices = search(criteria)
|
156
|
+
|
157
|
+
total = invoices.sum(:total_amount)
|
158
|
+
count = invoices.count
|
159
|
+
|
160
|
+
{
|
161
|
+
name: name,
|
162
|
+
total: total,
|
163
|
+
count: count,
|
164
|
+
invoices: invoices
|
165
|
+
}
|
166
|
+
end
|
167
|
+
|
168
|
+
# Get total by seller
|
169
|
+
def self.total_by_seller(seller_name, criteria = {})
|
170
|
+
total_by_party(:seller, seller_name, criteria)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Get total by buyer
|
174
|
+
def self.total_by_buyer(buyer_name, criteria = {})
|
175
|
+
total_by_party(:buyer, buyer_name, criteria)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Get statistics for a period
|
179
|
+
def self.stats_for_period(start_date, end_date, user = nil)
|
180
|
+
criteria = {
|
181
|
+
start_date: start_date,
|
182
|
+
end_date: end_date
|
183
|
+
}
|
184
|
+
|
185
|
+
# Filter by documents accessible to user if specified
|
186
|
+
if user && user.respond_to?(:readable_doc_ids)
|
187
|
+
doc_ids = user.readable_doc_ids
|
188
|
+
invoices = search(criteria).where(:doc_id.in => doc_ids)
|
189
|
+
else
|
190
|
+
invoices = search(criteria)
|
191
|
+
end
|
192
|
+
|
193
|
+
total = invoices.sum(:total_amount)
|
194
|
+
average = invoices.avg(:total_amount)
|
195
|
+
count = invoices.count
|
196
|
+
|
197
|
+
# Distribution by month
|
198
|
+
by_month = invoices.group_by { |inv| inv.issue_date&.beginning_of_month }
|
199
|
+
.transform_values { |invs| invs.sum(&:total_amount) }
|
200
|
+
.sort.to_h
|
201
|
+
|
202
|
+
{
|
203
|
+
start_date: start_date,
|
204
|
+
end_date: end_date,
|
205
|
+
total: total,
|
206
|
+
average: average,
|
207
|
+
count: count,
|
208
|
+
by_month: by_month
|
209
|
+
}
|
210
|
+
end
|
211
|
+
|
212
|
+
# Get statistics for current month
|
213
|
+
def self.stats_for_current_month(user = nil)
|
214
|
+
today = Date.today
|
215
|
+
start_date = today.beginning_of_month
|
216
|
+
end_date = today.end_of_month
|
217
|
+
|
218
|
+
stats_for_period(start_date, end_date, user)
|
219
|
+
end
|
220
|
+
|
221
|
+
# Get statistics for current quarter
|
222
|
+
def self.stats_for_current_quarter(user = nil)
|
223
|
+
today = Date.today
|
224
|
+
start_date = today.beginning_of_quarter
|
225
|
+
end_date = today.end_of_quarter
|
226
|
+
|
227
|
+
stats_for_period(start_date, end_date, user)
|
228
|
+
end
|
229
|
+
|
230
|
+
# Get statistics for current year
|
231
|
+
def self.stats_for_current_year(user = nil)
|
232
|
+
today = Date.today
|
233
|
+
start_date = today.beginning_of_year
|
234
|
+
end_date = today.end_of_year
|
235
|
+
|
236
|
+
stats_for_period(start_date, end_date, user)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require "document_types/invoice/version"
|
2
|
+
require "rails"
|
3
|
+
|
4
|
+
module DocumentTypes
|
5
|
+
module Invoice
|
6
|
+
class Engine < ::Rails::Engine
|
7
|
+
isolate_namespace DocumentTypes::Invoice
|
8
|
+
|
9
|
+
initializer "document_types.invoice.register" do |app|
|
10
|
+
# S'assurer que les classes de base sont déjà chargées
|
11
|
+
if !defined?(DocumentTypes::Registry) || !defined?(DocumentTypes::Base::DocumentTypeRecord)
|
12
|
+
Rails.logger.error "DocumentTypes base classes not loaded. Make sure to load document_types/base and document_types/registry before this gem."
|
13
|
+
end
|
14
|
+
|
15
|
+
# Load our models and services
|
16
|
+
require_relative "../../app/models/document_types/invoice_record"
|
17
|
+
require_relative "../../app/services/document_types/invoice_service"
|
18
|
+
|
19
|
+
# Register our document type in the registry
|
20
|
+
DocumentTypes::InvoiceRecord.register_type if defined?(DocumentTypes::Registry)
|
21
|
+
end
|
22
|
+
|
23
|
+
config.to_prepare do
|
24
|
+
# Make sure Doc model has the relationship with InvoiceRecord
|
25
|
+
if defined?(Doc) && defined?(DocumentTypable) && Doc.included_modules.include?(DocumentTypable)
|
26
|
+
Doc.class_eval do
|
27
|
+
has_one :invoice, class_name: "DocumentTypes::InvoiceRecord", dependent: :destroy
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: document_types-invoice
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Olivier
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-04-16 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 6.0.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: mongoid
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 7.0.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 7.0.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: nokogiri
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 1.10.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 1.10.0
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pdf-reader
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 2.4.0
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 2.4.0
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec-rails
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: Modular extension providing invoice document type functionality
|
84
|
+
email:
|
85
|
+
- olivier.dirrenberger@sinoia.fr
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- LICENSE.txt
|
91
|
+
- README.md
|
92
|
+
- Rakefile
|
93
|
+
- app/models/document_types/invoice_record.rb
|
94
|
+
- app/services/document_types/invoice_service.rb
|
95
|
+
- lib/document_types/invoice.rb
|
96
|
+
- lib/document_types/invoice/version.rb
|
97
|
+
homepage: https://code.plugandwork.net/plugandwork/document_types/invoice
|
98
|
+
licenses:
|
99
|
+
- MIT
|
100
|
+
metadata:
|
101
|
+
homepage_uri: https://code.plugandwork.net/plugandwork/document_types/invoice
|
102
|
+
source_code_uri: https://code.plugandwork.net/plugandwork/document_types/invoice
|
103
|
+
post_install_message:
|
104
|
+
rdoc_options: []
|
105
|
+
require_paths:
|
106
|
+
- lib
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: 2.6.0
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - ">="
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: '0'
|
117
|
+
requirements: []
|
118
|
+
rubygems_version: 3.3.7
|
119
|
+
signing_key:
|
120
|
+
specification_version: 4
|
121
|
+
summary: Invoice document type for Rails application
|
122
|
+
test_files: []
|