rodoo 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.
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Base class for Odoo models. Provides both class-level query methods and instance-level persistence.
5
+ #
6
+ # @example Defining a model
7
+ # class Contact < Rodoo::Model
8
+ # model_name "res.partner"
9
+ # end
10
+ #
11
+ # @example Querying
12
+ # contact = Rodoo::Contact.find(42)
13
+ # contacts = Rodoo::Contact.where([["is_company", "=", true]])
14
+ # all = Rodoo::Contact.all(limit: 10)
15
+ #
16
+ # @example Creating
17
+ # contact = Rodoo::Contact.create(name: "Acme Corp")
18
+ #
19
+ # @example Building and saving
20
+ # contact = Rodoo::Contact.new(name: "Draft")
21
+ # contact.email = "draft@example.com"
22
+ # contact.save
23
+ #
24
+ class Model
25
+ # ============================================
26
+ # Class-level configuration and query methods
27
+ # ============================================
28
+
29
+ # Sets or gets the Odoo model name for this model
30
+ #
31
+ # @param name [String, nil] The Odoo model name (e.g., "res.partner")
32
+ # @return [String] The model name
33
+ def self.model_name(name = nil)
34
+ if name
35
+ @odoo_model_name = name
36
+ else
37
+ @odoo_model_name || (superclass.respond_to?(:model_name) ? superclass.model_name : nil)
38
+ end
39
+ end
40
+
41
+ # Find a single record by ID
42
+ #
43
+ # @param id [Integer] The record ID
44
+ # @return [Model] The found record
45
+ # @raise [Rodoo::NotFoundError] If the record doesn't exist
46
+ #
47
+ # @example
48
+ # contact = Rodoo::Contact.find(42)
49
+ # contact.name # => "Acme Corp"
50
+ #
51
+ def self.find(id)
52
+ result = execute("read", ids: [id])
53
+ raise NotFoundError, "#{model_name} with id=#{id} not found" if result.nil? || result.empty?
54
+
55
+ new(result.first)
56
+ end
57
+
58
+ # Search for records with flexible query syntax
59
+ #
60
+ # @param conditions [Array, Hash, String, nil] Query conditions
61
+ # @param fields [Array<String>, nil] Fields to retrieve
62
+ # @param limit [Integer, nil] Maximum records to return
63
+ # @param offset [Integer, nil] Number of records to skip
64
+ # @return [Array<Model>] Array of matching records
65
+ #
66
+ # @example Keyword arguments (equality)
67
+ # Rodoo::Contact.where(name: "Acme", is_company: true)
68
+ #
69
+ # @example String condition
70
+ # Rodoo::Contact.where("credit_limit > 1000")
71
+ #
72
+ # @example Array of strings
73
+ # Rodoo::Contact.where(["credit_limit > 1000", "active = true"])
74
+ #
75
+ # @example Raw Odoo domain (array of arrays)
76
+ # Rodoo::Contact.where([["is_company", "=", true]], limit: 10)
77
+ #
78
+ def self.where(conditions = nil, fields: nil, limit: nil, offset: nil, **attrs)
79
+ domain = DomainBuilder.build(conditions, attrs)
80
+
81
+ params = { domain: domain }
82
+ params[:fields] = fields if fields
83
+ params[:limit] = limit if limit
84
+ params[:offset] = offset if offset
85
+
86
+ execute("search_read", params).map { |record| new(record) }
87
+ end
88
+
89
+ # Fetch all records (optionally limited)
90
+ #
91
+ # @param fields [Array<String>, nil] Fields to retrieve
92
+ # @param limit [Integer, nil] Maximum records to return
93
+ # @return [Array<Model>] Array of records
94
+ #
95
+ # @example
96
+ # all_contacts = Rodoo::Contact.all(limit: 100)
97
+ #
98
+ def self.all(fields: nil, limit: nil)
99
+ where([], fields: fields, limit: limit)
100
+ end
101
+
102
+ # Find a single record by attribute conditions
103
+ #
104
+ # @param conditions [Array, Hash, String, nil] Query conditions (same as where)
105
+ # @return [Model, nil] The first matching record or nil if not found
106
+ #
107
+ # @example Find by keyword arguments
108
+ # contact = Rodoo::Contact.find_by(email: "john@example.com")
109
+ #
110
+ # @example Find by multiple conditions
111
+ # contact = Rodoo::Contact.find_by(name: "Acme Corp", is_company: true)
112
+ #
113
+ # @example Find by string condition
114
+ # contact = Rodoo::Contact.find_by("credit_limit > 1000")
115
+ #
116
+ # @example Find by raw domain
117
+ # contact = Rodoo::Contact.find_by([["name", "ilike", "%acme%"]])
118
+ #
119
+ def self.find_by(conditions = nil, **attrs)
120
+ where(conditions, limit: 1, **attrs).first
121
+ end
122
+
123
+ # Find a single record by attribute conditions, raising if not found
124
+ #
125
+ # @param conditions [Array, Hash, String, nil] Query conditions (same as where)
126
+ # @return [Model] The first matching record
127
+ # @raise [Rodoo::NotFoundError] If no matching record is found
128
+ #
129
+ # @example Find by email (raises if not found)
130
+ # contact = Rodoo::Contact.find_by!(email: "john@example.com")
131
+ #
132
+ def self.find_by!(conditions = nil, **attrs)
133
+ record = find_by(conditions, **attrs)
134
+ return record if record
135
+
136
+ raise NotFoundError, "#{model_name} matching #{conditions.inspect} #{attrs.inspect} not found"
137
+ end
138
+
139
+ # Create a new record in Odoo
140
+ #
141
+ # @param attrs [Hash] Attributes for the new record
142
+ # @return [Model] The created record with its ID
143
+ #
144
+ # @example
145
+ # contact = Rodoo::Contact.create(name: "New Contact", email: "new@example.com")
146
+ # contact.id # => 123
147
+ #
148
+ def self.create(attrs)
149
+ ids = execute("create", vals_list: [attrs])
150
+ find(ids.first)
151
+ end
152
+
153
+ # Execute an Odoo method via the JSON-2 API
154
+ #
155
+ # @param method [String] The method to call (e.g., "search_read")
156
+ # @param params [Hash] The method parameters
157
+ # @return [Object] The response data
158
+ def self.execute(method, params = {})
159
+ Rodoo.connection.execute(model_name, method, params)
160
+ end
161
+
162
+ # ============================================
163
+ # Instance attributes and lifecycle
164
+ # ============================================
165
+
166
+ def initialize(attributes = {})
167
+ @attributes = (attributes || {}).transform_keys(&:to_sym)
168
+ end
169
+
170
+ def [](key)
171
+ @attributes[key.to_sym]
172
+ end
173
+
174
+ def []=(key, value)
175
+ @attributes[key.to_sym] = value
176
+ end
177
+
178
+ def to_h
179
+ @attributes.dup
180
+ end
181
+
182
+ def persisted?
183
+ !id.nil?
184
+ end
185
+
186
+ # Save the record to Odoo
187
+ #
188
+ # Creates a new record if unpersisted, updates if persisted.
189
+ #
190
+ # @return [self]
191
+ def save
192
+ if persisted?
193
+ update(to_h.except(:id))
194
+ else
195
+ result = self.class.create(to_h)
196
+ self.id = result.id
197
+ end
198
+ self
199
+ end
200
+
201
+ # Update specific attributes on a persisted record
202
+ #
203
+ # @param attrs [Hash] Attributes to update
204
+ # @return [self]
205
+ # @raise [Rodoo::Error] If the record hasn't been persisted
206
+ def update(attrs)
207
+ raise Error, "Cannot update a record that hasn't been persisted" unless persisted?
208
+
209
+ normalized = attrs.transform_keys(&:to_sym)
210
+ self.class.execute("write", ids: [id], vals: normalized)
211
+ normalized.each { |key, value| self[key] = value }
212
+ self
213
+ end
214
+
215
+ # Reload the record from Odoo
216
+ #
217
+ # @return [self]
218
+ # @raise [Rodoo::Error] If the record hasn't been persisted
219
+ def reload
220
+ raise Error, "Cannot reload a record that hasn't been persisted" unless persisted?
221
+
222
+ fresh = self.class.find(id)
223
+ fresh.to_h.each { |key, value| self[key] = value }
224
+ self
225
+ end
226
+
227
+ # Permanently delete the record from Odoo
228
+ #
229
+ # @return [self] The deleted record (frozen)
230
+ # @raise [Rodoo::Error] If the record hasn't been persisted
231
+ #
232
+ # @example
233
+ # contact = Rodoo::Contact.find(42)
234
+ # contact.destroy
235
+ # contact.destroyed? # => true
236
+ #
237
+ def destroy
238
+ raise Error, "Cannot destroy a record that hasn't been persisted" unless persisted?
239
+
240
+ self.class.execute("unlink", ids: [id])
241
+ @destroyed = true
242
+ freeze
243
+ end
244
+
245
+ # Check if this record has been destroyed
246
+ #
247
+ # @return [Boolean]
248
+ def destroyed?
249
+ @destroyed == true
250
+ end
251
+
252
+ def inspect
253
+ "#<#{self.class.name} id=#{id.inspect} #{inspectable_attributes}>"
254
+ end
255
+
256
+ private
257
+
258
+ def respond_to_missing?(_method_name, _include_private = false)
259
+ true
260
+ end
261
+
262
+ def method_missing(method_name, *args, &)
263
+ attr_name = method_name.to_s
264
+
265
+ if attr_name.end_with?("=")
266
+ @attributes[attr_name.delete_suffix("=").to_sym] = args.first
267
+ else
268
+ @attributes[method_name]
269
+ end
270
+ end
271
+
272
+ def inspectable_attributes
273
+ to_h.except(:id).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Base class for Odoo accounting entries (account.move).
5
+ #
6
+ # In Odoo, customer invoices, provider invoices, credit notes, and journal entries
7
+ # are all stored in the same model (account.move) and differentiated by the move_type field.
8
+ #
9
+ # This class provides the base functionality, while subclasses automatically filter
10
+ # and set the appropriate move_type.
11
+ #
12
+ # @example Using a specific subclass
13
+ # invoice = Rodoo::CustomerInvoice.create(partner_id: 42)
14
+ # bills = Rodoo::ProviderInvoice.where([["state", "=", "posted"]])
15
+ #
16
+ # @example Using the base class to query all types
17
+ # all_entries = Rodoo::AccountingEntry.where([["date", ">", "2025-01-01"]])
18
+ #
19
+ class AccountingEntry < Model
20
+ model_name "account.move"
21
+
22
+ # Subclasses override this to specify their move_type
23
+ #
24
+ # @return [String, nil] The move_type value for this class
25
+ def self.default_move_type
26
+ nil
27
+ end
28
+
29
+ # Search for records, automatically scoped to the move_type
30
+ #
31
+ # @param conditions [Array, Hash, String, nil] Query conditions
32
+ # @param options [Hash] Additional options (fields, limit, offset)
33
+ # @return [Array<AccountingEntry>] Array of matching records
34
+ def self.where(conditions = nil, **options)
35
+ domain = DomainBuilder.build(conditions, options.except(:fields, :limit, :offset))
36
+ domain = [["move_type", "=", default_move_type]] + domain if default_move_type
37
+ super(domain, **options.slice(:fields, :limit, :offset))
38
+ end
39
+
40
+ # Create a new record, automatically setting the move_type
41
+ #
42
+ # @param attrs [Hash] Attributes for the new record
43
+ # @return [AccountingEntry] The created record
44
+ def self.create(attrs)
45
+ scoped_attrs = if default_move_type
46
+ { move_type: default_move_type }.merge(attrs)
47
+ else
48
+ attrs
49
+ end
50
+ super(scoped_attrs)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Accounting entry line (account.move.line) - individual debit/credit lines within an accounting entry.
5
+ #
6
+ # Every accounting entry (invoice, bill, journal entry) has at least two lines:
7
+ # one for the debit side and one for the credit side.
8
+ #
9
+ # @example Find lines for a specific entry
10
+ # lines = Rodoo::AccountingEntryLine.where([["move_id", "=", 42]])
11
+ #
12
+ # @example Find all receivable lines
13
+ # receivables = Rodoo::AccountingEntryLine.where([["account_type", "=", "asset_receivable"]])
14
+ #
15
+ # @example Get line details
16
+ # line = Rodoo::AccountingEntryLine.find(123)
17
+ # line.debit # => 1000.0
18
+ # line.credit # => 0.0
19
+ # line.balance # => 1000.0
20
+ #
21
+ class AccountingEntryLine < Model
22
+ model_name "account.move.line"
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ class AnalyticAccount < Model
5
+ model_name "account.analytic.account"
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ class AnalyticPlan < Model
5
+ model_name "account.analytic.plan"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Contact model for Odoo's res.partner (contacts/companies)
5
+ #
6
+ # @example Find a contact
7
+ # contact = Rodoo::Contact.find(42)
8
+ #
9
+ # @example Search for companies
10
+ # companies = Rodoo::Contact.where([["is_company", "=", true]])
11
+ #
12
+ # @example Create a contact
13
+ # contact = Rodoo::Contact.create(name: "Acme Corp", is_company: true)
14
+ #
15
+ class Contact < Model
16
+ model_name "res.partner"
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Customer credit note / refund (move_type: out_refund)
5
+ #
6
+ # @example
7
+ # credit_note = Rodoo::CustomerCreditNote.create(partner_id: 42)
8
+ # credit_notes = Rodoo::CustomerCreditNote.where([["state", "=", "posted"]])
9
+ #
10
+ class CustomerCreditNote < AccountingEntry
11
+ def self.default_move_type
12
+ "out_refund"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Customer invoice (move_type: out_invoice)
5
+ #
6
+ # @example
7
+ # invoice = Rodoo::CustomerInvoice.create(partner_id: 42)
8
+ # invoices = Rodoo::CustomerInvoice.where([["state", "=", "posted"]])
9
+ #
10
+ class CustomerInvoice < AccountingEntry
11
+ def self.default_move_type
12
+ "out_invoice"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Journal entry (move_type: entry)
5
+ #
6
+ # @example
7
+ # entry = Rodoo::JournalEntry.create(journal_id: 1)
8
+ # entries = Rodoo::JournalEntry.where([["state", "=", "posted"]])
9
+ #
10
+ class JournalEntry < AccountingEntry
11
+ def self.default_move_type
12
+ "entry"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Product model for Odoo's product.product (product variants)
5
+ #
6
+ # @example Find a product
7
+ # product = Rodoo::Product.find(42)
8
+ #
9
+ # @example Search for active products
10
+ # products = Rodoo::Product.where([["active", "=", true]])
11
+ #
12
+ # @example Create a product
13
+ # product = Rodoo::Product.create(name: "Widget", list_price: 9.99)
14
+ #
15
+ class Product < Model
16
+ model_name "product.product"
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Project model for Odoo's project.project table.
5
+ #
6
+ # @example Find a project by ID
7
+ # project = Rodoo::Project.find(42)
8
+ #
9
+ # @example Search for projects
10
+ # project = Rodoo::Project.where([["is_company", "=", true]])
11
+ #
12
+ # @example Create a project
13
+ # project = Rodoo::Project.create(name: "my_project", account_id: analytic_account_id, allow_billable: true)
14
+ #
15
+ class Project < Model
16
+ model_name "project.project"
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Provider/vendor credit note / refund (move_type: in_refund)
5
+ #
6
+ # @example
7
+ # refund = Rodoo::ProviderCreditNote.create(partner_id: 42)
8
+ # refunds = Rodoo::ProviderCreditNote.where([["state", "=", "posted"]])
9
+ #
10
+ class ProviderCreditNote < AccountingEntry
11
+ def self.default_move_type
12
+ "in_refund"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ # Provider/vendor invoice (move_type: in_invoice)
5
+ #
6
+ # @example
7
+ # bill = Rodoo::ProviderInvoice.create(partner_id: 42)
8
+ # bills = Rodoo::ProviderInvoice.where([["state", "=", "posted"]])
9
+ #
10
+ class ProviderInvoice < AccountingEntry
11
+ def self.default_move_type
12
+ "in_invoice"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodoo
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rodoo.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rodoo/version"
4
+ require_relative "rodoo/configuration"
5
+ require_relative "rodoo/connection"
6
+ require_relative "rodoo/errors"
7
+ require_relative "rodoo/domain_builder"
8
+ require_relative "rodoo/model"
9
+
10
+ module Rodoo
11
+ autoload :AccountingEntry, "rodoo/models/accounting_entry"
12
+ autoload :AccountingEntryLine, "rodoo/models/accounting_entry_line"
13
+ autoload :AnalyticAccount, "rodoo/models/analytic_account"
14
+ autoload :AnalyticPlan, "rodoo/models/analytic_plan"
15
+ autoload :CustomerCreditNote, "rodoo/models/customer_credit_note"
16
+ autoload :CustomerInvoice, "rodoo/models/customer_invoice"
17
+ autoload :JournalEntry, "rodoo/models/journal_entry"
18
+ autoload :Contact, "rodoo/models/contact"
19
+ autoload :Product, "rodoo/models/product"
20
+ autoload :Project, "rodoo/models/project"
21
+ autoload :ProviderCreditNote, "rodoo/models/provider_credit_note"
22
+ autoload :ProviderInvoice, "rodoo/models/provider_invoice"
23
+
24
+ @configuration = nil
25
+ @connection = nil
26
+
27
+ def self.configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
31
+ def self.configure
32
+ yield(configuration)
33
+ @connection = nil # Reset connection when configuration changes
34
+ end
35
+
36
+ def self.reset!
37
+ @configuration = Configuration.new
38
+ @connection = nil
39
+ end
40
+
41
+ def self.connection
42
+ configuration.validate!
43
+ @connection ||= Connection.new(configuration)
44
+ end
45
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rodoo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rodrigo Serrano
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: This gem implements a wrapper to interact with Odoo's API in Ruby. The
13
+ API used is Odoo's 'external JSON-2 API' introduced in Odoo v19.
14
+ email:
15
+ - rodrigo.serrano@dekuple.es
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".env.example"
21
+ - ".rubocop.yml"
22
+ - CHANGELOG.md
23
+ - CLAUDE.md
24
+ - LICENSE.txt
25
+ - Odoo_API.md
26
+ - README.md
27
+ - Rakefile
28
+ - lib/rodoo.rb
29
+ - lib/rodoo/configuration.rb
30
+ - lib/rodoo/connection.rb
31
+ - lib/rodoo/domain_builder.rb
32
+ - lib/rodoo/errors.rb
33
+ - lib/rodoo/model.rb
34
+ - lib/rodoo/models/accounting_entry.rb
35
+ - lib/rodoo/models/accounting_entry_line.rb
36
+ - lib/rodoo/models/analytic_account.rb
37
+ - lib/rodoo/models/analytic_plan.rb
38
+ - lib/rodoo/models/contact.rb
39
+ - lib/rodoo/models/customer_credit_note.rb
40
+ - lib/rodoo/models/customer_invoice.rb
41
+ - lib/rodoo/models/journal_entry.rb
42
+ - lib/rodoo/models/product.rb
43
+ - lib/rodoo/models/project.rb
44
+ - lib/rodoo/models/provider_credit_note.rb
45
+ - lib/rodoo/models/provider_invoice.rb
46
+ - lib/rodoo/version.rb
47
+ homepage: https://github.com/dekuple/rodoo
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/dekuple/rodoo
52
+ source_code_uri: https://github.com/dekuple/rodoo
53
+ changelog_uri: https://github.com/dekuple/rodoo/blob/main/CHANGELOG.md
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 3.1.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.6.7
70
+ specification_version: 4
71
+ summary: Odoo API wrapper (using the modern JSON-2 API)
72
+ test_files: []