company 0.1.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e465582abe5e308b1726c5ef497f03eb8186b9ff1b0a4952c8ff0aefc9489f7
4
- data.tar.gz: e8597b7ad24485d5d4283fe4e89d3d2a575b21a54a6daf918036f21e12d81e64
3
+ metadata.gz: ed1f293ece38ef16d7b40c90e465a8c95dd66e4f722af9d01cff87cb78a2e793
4
+ data.tar.gz: b6c0d21d97439455d1cb50c751c3e8f8e4af13e406a0765d3b2f3f66c962c7c8
5
5
  SHA512:
6
- metadata.gz: 9acc31b2a77ddc5ad521ac5c976bdb430439aec207918e52a5c3b03f4e6a070514f572f127713d8ac500d65ac634a9415e5c95227f31a6d35e15e41c67be6fe2
7
- data.tar.gz: 5cbb020caabbc13e12b3535c4539de61c7c20b7a618de3f89283a67864a0e0051ba0f4720aff78ea6e8450658c2d2f72c33f954f5bab2eee84e139ee2c32fe52
6
+ metadata.gz: e337b418dee3bd35743e33a601624f51d1c8d438add6ddf90f1c692f23cf62c46b311010f92f33c39d94e0e3942e040f3709d80e2e6a6be45e91d4134ecd6243
7
+ data.tar.gz: 8d355dff05b2ddcb6447ab5f9eadd10e0129e51a2cfcc835a8d3801f2ea73bbec4fffed090fc2ed133ff888fb6da1a2e60cd547726afd33cc8999e7b42bf72da
data/CHANGELOG.md CHANGED
@@ -5,7 +5,66 @@ All notable changes to this project will be documented in this file.
5
5
  For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com) and
6
6
  [Vandamme](http://tech-angels.github.io/vandamme).
7
7
 
8
- ## [Unreleased]
8
+ ## 2.0.0 - 2026-09-15
9
+
10
+ * [Breaking change] `Company::Selection` takes the rule to keep records by as a block rather
11
+ than a technician, so one class answers `assigned_to`, `for_jobs` and `for_leads`. A gem
12
+ building one by hand passes a block where it passed `technician:`
13
+ * [Feature] `Company::Technician` -- id, name, surname -- a person the business sends out, and
14
+ `Company::Account#technicians`, the crew of the business
15
+ * [Feature] `Company::Visit#technicians`, whoever the stop is booked for
16
+ * [Feature] `Company::Collection#assigned_to(technician)`, the same list narrowed to what one
17
+ technician is on. A gem answers it where its platform can put the question to the server;
18
+ where none can, `Company::Selection` walks the list and keeps what the technician turns out
19
+ to be on, so a week of one person's timeslots reads the same on every platform. Narrowing by
20
+ technician and narrowing to a window commute
21
+ * [Feature] `Company::Visit#lead`, the lead a stop belongs to where it belongs to no job. A
22
+ visit is any booked time now, not only a job's: the stop to look at work nobody has priced
23
+ -- an assessment on Jobber, an estimate on Housecall Pro -- names a `lead` and no `job`, and
24
+ occupies a technician's day the same way. `Company::Quote` still means the price
25
+ * [Feature] `Company::Lead#location`, where the work would happen, so a stop booked against a
26
+ lead says where to go the way a job's stop does
27
+ * [Feature] `Company::Visits`, what a gem's list of visits subclasses: `create` books a stop
28
+ against a lead, taking the words `Leads#create` takes plus `starts_at:`, `ends_at:` and
29
+ `technicians:`, and `for_jobs` and `for_leads` narrow the list to one kind of stop
30
+
31
+ ## 1.0.0 - 2026-09-09
32
+
33
+ * [Feature] `Company::Account`, the gateway a set of credentials opens: a gem subclasses it
34
+ and answers `business`, `jobs`, `visits` and `leads` as its platform
35
+ offers them; a reader left out raises `NotImplementedError` naming the gem and the reader
36
+ * [Feature] `Company::Collection`, what a list of an account's records is: `Enumerable`, and
37
+ narrowed to a window measured from now by `upcoming(within)` and `past(within)`, both built
38
+ on the `between(from, to)` a gem answers; `ids` walks it for the IDs unless a gem knows a
39
+ cheaper way
40
+ * [Feature] `Company::Leads`, what files a lead: `create` takes the same words on every
41
+ platform -- name, surname, phone, email, address, description, notes, source -- and a gem
42
+ drops what its platform has no field for
43
+ * [Feature] `Company::Business` -- id, name, phone, subsidiaries -- who the credentials belong to.
44
+ The subsidiaries are the business itself and then every business under it at any depth,
45
+ flat, so the list is never empty; a gem whose platform nests them answers the ones directly
46
+ under it from the private `below`
47
+ * [Feature] `Company::Lead` -- id, customer -- and `Company::Quote` -- id, amount
48
+ * [Feature] `Company::Job` -- id, quote, description, notes, created_at, scheduled_at,
49
+ completed_at, amount, lines, location. The moments answer as Times however the platform
50
+ wrote them and the amounts as dollars in a BigDecimal
51
+ * [Feature] `Company::Line` -- id, name, description, quantity, amount -- a whole quantity read
52
+ whole
53
+ * [Feature] `Company::Visit` -- id, description, starts_at, ends_at, anytime?, job
54
+ * [Feature] `Company::Location` -- id, street, city, zip, latitude, longitude, customer -- and
55
+ `Company::Customer` -- id, name, surname, email, phone
56
+ * [Feature] A record reads the node its platform answered under either kind of key; a kind
57
+ names its attributes, a subclass names under `.keys` the node keys its platform spells
58
+ otherwise -- `{ phone: :phone_number }` -- and `node_keys` answers them through the map, so
59
+ a gem builds its query from what the vocabulary reads
60
+ * [Feature] A phone answers as the ten digits a North American number is, however the platform
61
+ wrote it, and as nil where none is held or none can be dialed
62
+ * [Feature] `Company::Error`, what every error a platform gem raises descends from, and
63
+ `Company::Throttled`, what a platform raises where it holds a request to a rate and answers
64
+ the same question a little later
65
+
66
+ The draft that had `Company` included the way `Enumerable` is, answering `read`, with an
67
+ `Account` record and a `Company::Mock`, never shipped.
9
68
 
10
69
  ## 0.1.0 - 2026-08-28
11
70
 
data/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Company
2
2
 
3
- Any company
3
+ One vocabulary for the records a field-service business keeps, whichever platform keeps them.
4
+ An account opens the business behind a set of credentials and the jobs, visits and leads it
5
+ holds; a gem that speaks to one platform subclasses `Company::Account` and the kinds it answers
6
+ for, and every reader is named here, once.
4
7
 
5
8
  ## How to install
6
9
 
@@ -10,16 +13,152 @@ To install on your system, run
10
13
 
11
14
  To use inside a bundled Ruby project, add this line to the `Gemfile`:
12
15
 
13
- gem 'company', '~> 0.1.0'
16
+ gem 'company', '~> 2.0'
14
17
 
15
- Below 1.0 the pin names the patch as well as the minor, so `bundle update` stops short of
16
- `0.2.0`. Semantic Versioning lets a `0.x` release break whatever it likes, and only promises
17
- otherwise once the major is real -- at which point the pin loosens to `~> 1.0`.
18
+ Semantic Versioning promises that `~> major.minor` never crosses a breaking change, so the pin
19
+ takes every 2.x release and stops short of 3.0.
20
+
21
+ ## How it reads
22
+
23
+ Given a gem that answers the vocabulary -- call it Acme -- a caller never learns how Acme's
24
+ platform spells a field, pages a list or counts its money:
25
+
26
+ ```ruby
27
+ account = Acme::Account.new credentials # => #<Acme::Account>
28
+
29
+ business = account.business # => #<Acme::Business>
30
+ business.id # => '42'
31
+ business.name # => 'Acme, Inc.'
32
+ business.phone # => '5555555555'
33
+ business.subsidiaries # => [#<Acme::Business>, ...]
34
+
35
+ jobs = account.jobs.past(3.months) # => #<Acme::Jobs>
36
+ jobs.ids # => ['j1', 'j2', ...]
37
+
38
+ job = jobs.first
39
+ job.id # => 'j1'
40
+ job.description # => 'Repair'
41
+ job.notes # => 'Ring twice'
42
+ job.created_at # => 2026-09-03 06:00:00 UTC
43
+ job.scheduled_at # => 2026-09-10 01:00:00 UTC
44
+ job.completed_at # => nil
45
+ job.amount # => 19.99
46
+
47
+ quote = job.quote # => #<Acme::Quote>, or nil where the job was won without one
48
+ quote.id # => 'q1'
49
+ quote.amount # => 24.99
50
+
51
+ line = job.lines.first # => #<Acme::Line>
52
+ line.id # => 'l1'
53
+ line.name # => 'Labor'
54
+ line.description # => 'Hours to fix the issue'
55
+ line.quantity # => 1.5
56
+ line.amount # => 24.99
57
+
58
+ location = job.location # => #<Acme::Location>
59
+ location.id # => 'a1'
60
+ location.street # => '100 Acme Circle'
61
+ location.city # => 'Springfield'
62
+ location.zip # => '98920'
63
+ location.latitude # => 45.2335
64
+ location.longitude # => -9.1234
65
+
66
+ customer = location.customer # => #<Acme::Customer>
67
+ customer.id # => 'c1'
68
+ customer.name # => 'Jane'
69
+ customer.surname # => 'Qi'
70
+ customer.email # => 'jane@example.com'
71
+ customer.phone # => '5555555666'
72
+
73
+ visits = account.visits.upcoming(1.day) # => #<Acme::Visits>, stops of jobs and of leads
74
+ visits.ids # => ['v1', 'v2', ...]
75
+
76
+ visit = visits.first # => #<Acme::Visit>
77
+ visit.id # => 'v1'
78
+ visit.description # => 'Service appointment'
79
+ visit.starts_at # => 2026-09-10 01:00:00 UTC
80
+ visit.ends_at # => 2026-09-10 02:00:00 UTC
81
+ visit.anytime? # => false
82
+ visit.job # => job, or nil where the stop is a lead's
83
+ visit.lead # => lead, or nil where the stop is a job's
84
+ visit.technicians # => [#<Acme::Technician>, ...]
85
+
86
+ visits.for_jobs # => only the stops of jobs
87
+ visits.for_leads # => only the stops of leads
88
+
89
+ booked = account.visits.create name: 'Jane', surname: 'Qi', phone: '5555555666',
90
+ email: 'jane@example.com', address: { street: '100 Acme Circle', zip: '98920' },
91
+ description: 'Repair', notes: 'Estimate $20-$30', source: 'Website',
92
+ starts_at: 1.day.from_now, ends_at: 1.day.from_now + 1.hour, technicians: [technician]
93
+ booked.lead # => #<Acme::Lead>, opened with the stop
94
+ booked.lead.location # => #<Acme::Location>, where to go
95
+
96
+ technician = account.technicians.first # => #<Acme::Technician>
97
+ technician.id # => 't1'
98
+ technician.name # => 'Grace'
99
+ technician.surname # => 'Hopper'
100
+
101
+ week = account.visits.between(monday, sunday).assigned_to(technician)
102
+ week.ids # => ['v1', 'v2', ...]
103
+
104
+ lead = account.leads.create name: 'Jane', surname: 'Qi', phone: '5555555666',
105
+ email: 'jane@example.com', address: { street: '100 Acme Circle', zip: '98920' },
106
+ description: 'Repair', notes: 'Estimate $20–$30', source: 'Website'
107
+ lead.id # => 'd1'
108
+ lead.customer # => #<Acme::Customer>
109
+ ```
110
+
111
+ A moment reads as a `Time`, an amount as dollars in a `BigDecimal`, a phone as the ten digits to
112
+ dial, and a field the platform holds nothing for as nil. A list is walked a page at a time, as
113
+ far as it goes or narrowed: to a window measured from now, to the technician the records are
114
+ booked for, or to one kind of stop. The narrowings compose in any order, so one technician's
115
+ week reads the same whichever is asked for first.
116
+
117
+ A visit is any booked time, not only work that is already a job. A stop to look at something
118
+ nobody has priced yet -- Jobber calls it an assessment, Housecall Pro an estimate -- is a visit
119
+ that names a `lead` and no `job`, and it occupies the technician's day exactly as a job's stop
120
+ does. `quote` stays the price, which is the other half of what Housecall Pro files as one
121
+ record.
122
+
123
+ ## Answering as a gem
124
+
125
+ A gem subclasses `Company::Account` and answers the readers its platform offers, and subclasses
126
+ a kind wherever its platform spells a key otherwise than the vocabulary:
127
+
128
+ ```ruby
129
+ class Acme::Account < Company::Account
130
+ def business = Business.new node: read('company')
131
+ def jobs = Jobs.new account: self
132
+ end
133
+
134
+ class Acme::Business < Company::Business
135
+ def self.keys = { phone: :phone_number }
136
+ end
137
+
138
+ class Acme::Jobs < Company::Collection
139
+ def each = ... # walk the platform's pages, yielding a Job each
140
+ def between(from, to) = ... # the same list, narrowed to what starts between the two
141
+ end
142
+ ```
143
+
144
+ `Company::Business.node_keys` then answers `[:id, :name, :phone_number]`: exactly what to ask
145
+ the platform for. A reader the gem leaves out raises `NotImplementedError` naming the gem; leads
146
+ it leaves out refuse to file one, and so do the visits. `assigned_to`, `for_jobs` and
147
+ `for_leads` a gem leaves out still answer: `Company::Selection` walks the list and lets through
148
+ what the rule keeps, so only a platform that can put the question to its server writes the
149
+ method, and only to save the requests the walk would spend. The least a gem writes is under `test/acme`, and the test that
150
+ runs every reader through it, `test/company/acme_test.rb`, reads as a tutorial.
151
+
152
+ ## Errors
153
+
154
+ Every error a gem raises descends from `Company::Error`, so one rescue catches the lot, and one
155
+ for a request held to a rate from `Company::Throttled`, so one retry covers every platform.
18
156
 
19
157
  ## Development
20
158
 
21
159
  `bin/setup` gets a clone working, `bin/console` opens a prompt with the library loaded, and
22
- `bundle exec rake` runs the suite and the linter -- which is what CI runs too.
160
+ `bundle exec rake` runs the suite, the linter and the two size limits -- which is what CI runs
161
+ too.
23
162
 
24
163
  ## Reference
25
164
 
@@ -0,0 +1,25 @@
1
+ module Company
2
+ # The gateway a set of credentials opens on a platform: the business they belong to and the
3
+ # records it holds. A gem subclasses it and answers what its platform offers; a reader it
4
+ # leaves out raises NotImplementedError, and the leads it leaves out refuse to file one.
5
+ class Account
6
+ # @return [Business] business the credentials belong to.
7
+ def business = unanswered :business
8
+
9
+ # @return [Collection] jobs of the business, each a {Job}; a platform may also `find` one.
10
+ def jobs = unanswered :jobs
11
+
12
+ # @return [Visits] visits of the business, each a {Visit}; a platform may also `find` one.
13
+ def visits = unanswered :visits
14
+
15
+ # @return [Collection] technicians of the business, each a {Technician}.
16
+ def technicians = unanswered :technicians
17
+
18
+ # @return [Leads] leads of the business: `create` files one, where the platform takes leads.
19
+ def leads = Leads.new
20
+
21
+ private
22
+
23
+ def unanswered(name) = raise NotImplementedError, "#{self.class} does not answer #{name}"
24
+ end
25
+ end
@@ -0,0 +1,37 @@
1
+ module Company
2
+ # A list of records an account holds, walked however the platform pages it and narrowed to a
3
+ # window measured from now, or to one technician. A gem answers `each`, and `between` with
4
+ # the same list narrowed to what starts between two moments, either one open.
5
+ class Collection
6
+ include Enumerable
7
+
8
+ # Both ends are measured from one moment, so nothing slides between them.
9
+ # @param within [ActiveSupport::Duration, nil] how far ahead to look, or as far as there is.
10
+ # @return [Collection] the same list, narrowed to what starts from now on.
11
+ def upcoming(within = nil)
12
+ now = Time.now
13
+ between now, within && now + within
14
+ end
15
+
16
+ # @param within [ActiveSupport::Duration, nil] how far back to look, or as far as there is.
17
+ # @return [Collection] the same list, narrowed to what started before now.
18
+ def past(within = nil)
19
+ now = Time.now
20
+ between within && now - within, now
21
+ end
22
+
23
+ # A platform that can ask its server for one technician's work narrows the list there; one
24
+ # that cannot walks the list and keeps what the technician turns out to be on.
25
+ # @param technician [Technician] whoever the work is booked for.
26
+ # @return [Collection] the same list, narrowed to what they are assigned to.
27
+ def assigned_to(technician)
28
+ Selection.new(collection: self) do |record|
29
+ record.technicians.any? { |each| each.id == technician.id }
30
+ end
31
+ end
32
+
33
+ # @return [Array<String>] ID of every record, the list walked; a platform with a cheaper
34
+ # way to ask answers it that way instead.
35
+ def ids = map(&:id)
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ module Company
2
+ # The leads of a business: who asked it for work, before there is a job. A gem answers
3
+ # `create` with the lead its platform filed, dropping what the platform has no field for.
4
+ class Leads
5
+ # @param name [String] given name of who asked, or the business's where a person has none.
6
+ # @param surname [String, nil] their surname.
7
+ # @param phone [String, nil] number they are reached on.
8
+ # @param email [String, nil] address they are written to.
9
+ # @param address [Hash, nil] where the work would happen: :street, :city, :state and :zip.
10
+ # @param description [String] what the work is called.
11
+ # @param notes [String, nil] what else was said about it.
12
+ # @param source [String, nil] where the lead came from, as the business names its sources.
13
+ # @return [Lead] the lead as the platform filed it.
14
+ def create(name:, surname:, phone:, email:, address:, description:, notes:, source:)
15
+ raise NotImplementedError, "#{self.class} does not file a lead"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ module Company
2
+ # The visits of a business: every stop somebody is booked for, of a job or of a lead where
3
+ # the work is still being looked at. A gem answers `create` where its platform books a stop
4
+ # against a lead, and `for_jobs` or `for_leads` where it can ask its platform for one kind
5
+ # alone; where it cannot, the list is walked and the other kind let go.
6
+ class Visits < Collection
7
+ # Books a stop to look at work nobody has priced yet, opening the customer and whatever the
8
+ # platform hangs the stop off wherever it has none.
9
+ # @param name [String] given name of who asked, or the business's where a person has none.
10
+ # @param surname [String, nil] their surname.
11
+ # @param phone [String, nil] number they are reached on.
12
+ # @param email [String, nil] address they are written to.
13
+ # @param address [Hash, nil] where the work would happen: :street, :city, :state and :zip.
14
+ # @param description [String] what the work is called.
15
+ # @param notes [String, nil] what else was said about it.
16
+ # @param source [String, nil] where the lead came from, as the business names its sources.
17
+ # @param starts_at [Time] moment somebody is booked to arrive.
18
+ # @param ends_at [Time, nil] moment they are booked to leave, or nothing for the platform's.
19
+ # @param technicians [Array<Technician>] whoever is booked to go.
20
+ # @return [Visit] stop as the platform booked it.
21
+ def create(name:, surname:, phone:, email:, address:, description:, notes:, source:,
22
+ starts_at:, ends_at:, technicians:)
23
+ raise NotImplementedError, "#{self.class} does not book a visit"
24
+ end
25
+
26
+ # @return [Collection] the same list, narrowed to the stops of jobs.
27
+ def for_jobs = Selection.new(collection: self, &:job)
28
+
29
+ # @return [Collection] the same list, narrowed to the stops of leads.
30
+ def for_leads = Selection.new(collection: self, &:lead)
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ module Company
2
+ # What every error a company raises descends from, so one rescue still catches the lot.
3
+ class Error < StandardError
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ module Company
2
+ # Raised where a platform holds a request to a rate: the same question asked a little later
3
+ # is answered. Nothing here sleeps; a caller with a queue brings the whole job back.
4
+ class Throttled < Error
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ module Company
2
+ # The ten digits a North American number is, whatever punctuation it arrived wearing.
3
+ class Phone
4
+ # Ten digits whose area code and exchange open with 2 through 9.
5
+ NANP = /\A[2-9]\d{2}[2-9]\d{6}\z/
6
+
7
+ # @param number [String, nil] number as the platform holds it.
8
+ # @return [String, nil] ten digits to dial, or nil where none is held or none can be dialed.
9
+ def self.from(number)
10
+ digits = number.to_s.delete('^0-9').delete_prefix '1'
11
+ digits if NANP.match? digits
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,42 @@
1
+ module Company
2
+ # What every record shares: the node the platform answered it as. Built by the gem that read
3
+ # it, never by a caller.
4
+ class Resource
5
+ # The node keys the platform spells otherwise than the vocabulary, by the reader they
6
+ # answer: `{ phone: :phone_number }` reads {Business#phone} off `phone_number`. A gem
7
+ # declares its own; a reader left out reads the key of its own name.
8
+ # @return [Hash] reader-to-key exceptions, empty where every name is its key.
9
+ def self.keys = {}
10
+
11
+ # The keys a platform is expected to answer a node with: each attribute the kind reads,
12
+ # through {.keys}, so a gem asks its platform for exactly these and writes none by hand.
13
+ # @return [Array<Symbol>] node keys, in the order the attributes are read.
14
+ def self.node_keys = attributes.map { |name| keys.fetch name, name }
15
+
16
+ # @param node [Hash] record as the platform answered it, under either kind of key.
17
+ def initialize(node: {})
18
+ @node = node.with_indifferent_access
19
+ end
20
+
21
+ # @return [String] ID the platform files the record under.
22
+ def id = attribute :id
23
+
24
+ private
25
+
26
+ def attribute(name) = @node[self.class.keys.fetch(name, name)]
27
+
28
+ def time(name)
29
+ value = attribute name
30
+ value.is_a?(String) ? (Time.iso8601 value if value.present?) : value
31
+ end
32
+
33
+ def decimal(name)
34
+ value = attribute name
35
+ BigDecimal value.to_s if value.present?
36
+ end
37
+
38
+ def record(type, key) = (type.new node: @node[key] if @node[key])
39
+
40
+ def records(type, key) = Array(@node[key]).map { |each| type.new node: each }
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ module Company
2
+ # Who the account belongs to: the one business behind a set of credentials.
3
+ class Business < Resource
4
+ # What every business reads, by the vocabulary's names.
5
+ def self.attributes = %i[id name phone]
6
+
7
+ # @return [String] business name.
8
+ def name = attribute :name
9
+
10
+ # @return [String, nil] ten digits the business is reached on, nil where none can be dialed.
11
+ def phone = Phone.from attribute(:phone)
12
+
13
+ # Itself first, then every business under it at any depth, in the order the platform lists
14
+ # them: a business on its own is its own one subsidiary, and a franchise reads flat. A gem
15
+ # whose platform nests businesses answers the ones directly under this one from the private
16
+ # `below`; the rest inherit none.
17
+ # @return [Array<Business>] businesses an account may act as, this one included.
18
+ def subsidiaries = [ self, *below.flat_map(&:subsidiaries) ]
19
+
20
+ private
21
+
22
+ def below = []
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module Company
2
+ # A person the business works for.
3
+ class Customer < Resource
4
+ # What every customer reads, by the vocabulary's names.
5
+ def self.attributes = %i[id name surname email phone]
6
+
7
+ # @return [String, nil] what they go by: a given name, or a business's where a person has none.
8
+ def name = attribute :name
9
+
10
+ # @return [String, nil] their surname.
11
+ def surname = attribute :surname
12
+
13
+ # @return [String, nil] address they are written to.
14
+ def email = attribute :email
15
+
16
+ # @return [String, nil] ten digits they are reached on, nil where none can be dialed.
17
+ def phone = Phone.from attribute(:phone)
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ module Company
2
+ # Work the business accepted and scheduled.
3
+ class Job < Resource
4
+ # What every job reads, by the vocabulary's names.
5
+ def self.attributes = %i[id description notes created_at scheduled_at completed_at amount]
6
+
7
+ # @return [String, nil] what the work is called, in the words of whoever opened the job.
8
+ def description = attribute :description
9
+
10
+ # @return [String, nil] what was written on the job for the crew to mind.
11
+ def notes = attribute :notes
12
+
13
+ # @return [Time] moment the job was opened.
14
+ def created_at = time :created_at
15
+
16
+ # @return [Time, nil] moment the work is booked for.
17
+ def scheduled_at = time :scheduled_at
18
+
19
+ # @return [Time, nil] moment the work was finished.
20
+ def completed_at = time :completed_at
21
+
22
+ # @return [BigDecimal, nil] what the job comes to, in dollars.
23
+ def amount = decimal :amount
24
+
25
+ # @return [Quote, nil] quote the job was won with, where it came back beside the job.
26
+ def quote = record Quote, :quote
27
+
28
+ # @return [Array<Line>] lines the work is billed as, empty where none came back.
29
+ def lines = records Line, :lines
30
+
31
+ # @return [Location, nil] where the work happens, where it came back beside the job.
32
+ def location = record Location, :location
33
+ end
34
+ end
@@ -0,0 +1,13 @@
1
+ module Company
2
+ # Somebody who asked the business for work and is not a customer yet.
3
+ class Lead < Resource
4
+ # What every lead reads, by the vocabulary's names.
5
+ def self.attributes = %i[id]
6
+
7
+ # @return [Customer, nil] customer the lead was filed for, where they came back beside it.
8
+ def customer = record Customer, :customer
9
+
10
+ # @return [Location, nil] where the work would happen, where the platform named it.
11
+ def location = record Location, :location
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module Company
2
+ # One line of a document: how many of a thing, what it is called, and what it comes to.
3
+ class Line < Resource
4
+ # What every line reads, by the vocabulary's names.
5
+ def self.attributes = %i[id name description quantity amount]
6
+
7
+ # @return [String, nil] what the line is called.
8
+ def name = attribute :name
9
+
10
+ # @return [String, nil] what the line says beyond what it is called.
11
+ def description = attribute :description
12
+
13
+ # A whole quantity reads as an Integer -- `3 Faucets` rather than `3.0 Faucets` -- and a
14
+ # fraction keeps its point, since rounding it would lie about what was billed.
15
+ # @return [Integer, Float, nil] how many of it the document is for.
16
+ def quantity = whole attribute(:quantity)
17
+
18
+ # @return [BigDecimal, nil] what the line comes to, in dollars.
19
+ def amount = decimal :amount
20
+
21
+ private
22
+
23
+ def whole(number) = number && ((number % 1).zero? ? number.to_i : number)
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ module Company
2
+ # Where work happens: one address the business drives to.
3
+ class Location < Resource
4
+ # What every location reads, by the vocabulary's names.
5
+ def self.attributes = %i[id street city zip latitude longitude]
6
+
7
+ # @return [String, nil] street, number included.
8
+ def street = attribute :street
9
+
10
+ # @return [String, nil] town.
11
+ def city = attribute :city
12
+
13
+ # @return [String, nil] ZIP code.
14
+ def zip = attribute :zip
15
+
16
+ # @return [Numeric, nil] how far north the place is.
17
+ def latitude = attribute :latitude
18
+
19
+ # @return [Numeric, nil] how far east the place is.
20
+ def longitude = attribute :longitude
21
+
22
+ # @return [Customer, nil] whose place it is, where they came back beside the location.
23
+ def customer = record Customer, :customer
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ module Company
2
+ # A price the business sent a customer, which a job names where it was won with one.
3
+ class Quote < Resource
4
+ # What every quote reads, by the vocabulary's names.
5
+ def self.attributes = %i[id amount]
6
+
7
+ # @return [BigDecimal, nil] what the quote comes to, in dollars.
8
+ def amount = decimal :amount
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module Company
2
+ # A person the business sends out: whoever a visit is booked for.
3
+ class Technician < Resource
4
+ # What every technician reads, by the vocabulary's names.
5
+ def self.attributes = %i[id name surname]
6
+
7
+ # @return [String, nil] what they go by: a given name.
8
+ def name = attribute :name
9
+
10
+ # @return [String, nil] their surname.
11
+ def surname = attribute :surname
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ module Company
2
+ # One stop of a job, or of a lead where the work is still being looked at: when somebody is
3
+ # booked to be somewhere, and what they are booked for.
4
+ class Visit < Resource
5
+ # What every visit reads, by the vocabulary's names.
6
+ def self.attributes = %i[id description starts_at ends_at anytime]
7
+
8
+ # @return [String, nil] what the stop is, in the words of whoever booked it.
9
+ def description = attribute :description
10
+
11
+ # @return [Time, nil] moment the visit is booked to start.
12
+ def starts_at = time :starts_at
13
+
14
+ # @return [Time, nil] moment the visit is booked to end.
15
+ def ends_at = time :ends_at
16
+
17
+ # @return [Boolean, nil] whether the visit may happen any time that day rather than at an hour.
18
+ def anytime? = attribute :anytime
19
+
20
+ # @return [Job, nil] job the stop belongs to, nil where the stop is a lead's.
21
+ def job = record Job, :job
22
+
23
+ # @return [Lead, nil] lead the stop belongs to, nil where the stop is a job's.
24
+ def lead = record Lead, :lead
25
+
26
+ # @return [Array<Technician>] whoever the stop is booked for, where the platform names them.
27
+ def technicians = records Technician, :technicians
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ module Company
2
+ # A list narrowed by something the platform could not be asked to narrow by: the list it came
3
+ # from is walked as it was, and only what the rule keeps comes through.
4
+ class Selection < Collection
5
+ # @param collection [Collection] list to walk.
6
+ # @yield [Resource] each record of it, to keep or to let go.
7
+ def initialize(collection:, &kept)
8
+ @collection = collection
9
+ @kept = kept
10
+ end
11
+
12
+ # @yield [Resource] each record the rule kept, in the order the list answered it.
13
+ def each
14
+ @collection.each { |record| yield record if @kept.call record }
15
+ end
16
+
17
+ # @param from [Time, nil] moment the window opens, or nothing for as far back as it goes.
18
+ # @param to [Time, nil] moment the window closes, or nothing for as far ahead as it goes.
19
+ # @return [Selection] the same rule, over the list narrowed to the window.
20
+ def between(from, to) = self.class.new(collection: @collection.between(from, to), &@kept)
21
+ end
22
+ end
@@ -1,4 +1,5 @@
1
+ # The vocabulary two platform gems share: an account opens a business and the records it holds.
1
2
  module Company
2
3
  # The version of this gem, as RubyGems knows it.
3
- VERSION = '0.1.0'
4
+ VERSION = '2.0.0'
4
5
  end
data/lib/company.rb CHANGED
@@ -1,5 +1,32 @@
1
+ require 'bigdecimal'
2
+ require 'time'
3
+
1
4
  require 'company/version'
2
5
 
3
- # The namespace the company gem reserves for what it will hold.
4
- module Company
5
- end
6
+ # Only the Active Support files whose methods are used: a name a platform holds nothing for
7
+ # arrives as readily empty as null, a node reads under either kind of key, the lines of a job
8
+ # join into one sentence, and a moment plus a duration is a moment.
9
+ require 'active_support'
10
+ require 'active_support/core_ext/object/blank'
11
+ require 'active_support/core_ext/hash/indifferent_access'
12
+ require 'active_support/core_ext/array/conversions'
13
+ require 'active_support/core_ext/numeric/time'
14
+
15
+ require 'company/error'
16
+ require 'company/errors/throttled'
17
+ require 'company/phone'
18
+ require 'company/resource'
19
+ require 'company/collection'
20
+ require 'company/selection'
21
+ require 'company/collections/leads'
22
+ require 'company/collections/visits'
23
+ require 'company/account'
24
+ require 'company/resources/business'
25
+ require 'company/resources/customer'
26
+ require 'company/resources/technician'
27
+ require 'company/resources/location'
28
+ require 'company/resources/line'
29
+ require 'company/resources/lead'
30
+ require 'company/resources/quote'
31
+ require 'company/resources/visit'
32
+ require 'company/resources/job'
metadata CHANGED
@@ -1,15 +1,30 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: company
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: A library to expose endpoint for a generic company
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: The records a field-service business holds, whichever platform holds
27
+ them
13
28
  email:
14
29
  - claudiob@users.noreply.github.com
15
30
  executables: []
@@ -20,6 +35,24 @@ files:
20
35
  - LICENSE.txt
21
36
  - README.md
22
37
  - lib/company.rb
38
+ - lib/company/account.rb
39
+ - lib/company/collection.rb
40
+ - lib/company/collections/leads.rb
41
+ - lib/company/collections/visits.rb
42
+ - lib/company/error.rb
43
+ - lib/company/errors/throttled.rb
44
+ - lib/company/phone.rb
45
+ - lib/company/resource.rb
46
+ - lib/company/resources/business.rb
47
+ - lib/company/resources/customer.rb
48
+ - lib/company/resources/job.rb
49
+ - lib/company/resources/lead.rb
50
+ - lib/company/resources/line.rb
51
+ - lib/company/resources/location.rb
52
+ - lib/company/resources/quote.rb
53
+ - lib/company/resources/technician.rb
54
+ - lib/company/resources/visit.rb
55
+ - lib/company/selection.rb
23
56
  - lib/company/version.rb
24
57
  homepage: https://github.com/claudiob/company
25
58
  licenses:
@@ -36,7 +69,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
36
69
  requirements:
37
70
  - - ">="
38
71
  - !ruby/object:Gem::Version
39
- version: '3'
72
+ version: 3.2.0
40
73
  required_rubygems_version: !ruby/object:Gem::Requirement
41
74
  requirements:
42
75
  - - ">="