hcp 1.4.0 → 3.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 +4 -4
- data/.yardopts +0 -1
- data/CHANGELOG.md +75 -0
- data/CLAUDE.md +34 -9
- data/README.md +99 -125
- data/lib/hcp/account.rb +25 -0
- data/lib/hcp/answer.rb +3 -11
- data/lib/hcp/client.rb +41 -0
- data/lib/hcp/collections/estimates.rb +22 -0
- data/lib/hcp/collections/jobs.rb +32 -0
- data/lib/hcp/collections/leads.rb +25 -0
- data/lib/hcp/collections/technicians.rb +23 -0
- data/lib/hcp/collections/visits.rb +80 -0
- data/lib/hcp/error.rb +1 -1
- data/lib/hcp/errors/throttled.rb +4 -0
- data/lib/hcp/event.rb +1 -6
- data/lib/hcp/resources/business.rb +11 -0
- data/lib/hcp/resources/customer.rb +9 -45
- data/lib/hcp/resources/estimate.rb +40 -46
- data/lib/hcp/resources/job.rb +58 -69
- data/lib/hcp/resources/lead.rb +26 -0
- data/lib/hcp/resources/line.rb +8 -0
- data/lib/hcp/resources/location.rb +7 -0
- data/lib/hcp/resources/quote.rb +28 -0
- data/lib/hcp/resources/technician.rb +7 -0
- data/lib/hcp/resources/visit.rb +45 -0
- data/lib/hcp/version.rb +3 -3
- data/lib/hcp.rb +22 -41
- metadata +30 -28
- data/lib/hcp/access.rb +0 -12
- data/lib/hcp/concerns/chainable.rb +0 -39
- data/lib/hcp/concerns/keyed.rb +0 -14
- data/lib/hcp/concerns/named.rb +0 -7
- data/lib/hcp/concerns/queryable.rb +0 -41
- data/lib/hcp/concerns/scheduled.rb +0 -16
- data/lib/hcp/concerns/statused.rb +0 -23
- data/lib/hcp/concerns/timestamped.rb +0 -10
- data/lib/hcp/errors/not_found.rb +0 -4
- data/lib/hcp/errors/too_many_requests.rb +0 -16
- data/lib/hcp/filter.rb +0 -36
- data/lib/hcp/key.rb +0 -24
- data/lib/hcp/lead/pipeline.rb +0 -36
- data/lib/hcp/lead.rb +0 -44
- data/lib/hcp/relation.rb +0 -76
- data/lib/hcp/request.rb +0 -31
- data/lib/hcp/resource.rb +0 -58
- data/lib/hcp/resources/address.rb +0 -24
- data/lib/hcp/resources/booking_window.rb +0 -52
- data/lib/hcp/resources/company.rb +0 -42
- data/lib/hcp/resources/employee.rb +0 -12
- data/lib/hcp/resources/estimate/option.rb +0 -26
- data/lib/hcp/resources/job/appointment.rb +0 -22
- data/lib/hcp/resources/job/invoice.rb +0 -21
- data/lib/hcp/resources/line_item.rb +0 -20
- data/lib/hcp/resources/note.rb +0 -6
- data/lib/hcp/resources/schedule.rb +0 -21
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The leads of one location: who asked a Housecall Pro user for work, before there is a job.
|
|
3
|
+
class Leads < Company::Leads
|
|
4
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
5
|
+
def initialize(client:)
|
|
6
|
+
@client = client
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Opens a lead, and a customer with it, on Housecall Pro. A lead has no title of its own, so
|
|
10
|
+
# the description heads the note and the notes follow it.
|
|
11
|
+
# @return [Lead] lead as Housecall Pro filed it, with its customer's ID beside it.
|
|
12
|
+
def create(name:, surname:, phone:, email:, address:, description:, notes:, source:)
|
|
13
|
+
customer = { first_name: name, last_name: surname, email: email, mobile_number: phone,
|
|
14
|
+
lead_source: source, }
|
|
15
|
+
body = { customer: customer.compact_blank, address: address, lead_source: source,
|
|
16
|
+
note: [ description, notes ].compact_blank.join("\n"), }
|
|
17
|
+
Lead.new node: @client.post('leads', body.compact_blank), client: @client
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Reaches the network for nothing: the lead is named, and moved once it is asked to be.
|
|
21
|
+
# @param id [String] ID Housecall Pro files the lead under.
|
|
22
|
+
# @return [Lead] the lead, able to move through the pipeline.
|
|
23
|
+
def find(id) = Lead.new node: { id: id }, client: @client
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The employees of one location, walked a page at a time. Housecall Pro lists the active
|
|
3
|
+
# ones and no others, so a crew that has left the business is not among them.
|
|
4
|
+
class Technicians < Company::Collection
|
|
5
|
+
# Employees a page: the most Housecall Pro answers with, and more than it refuses.
|
|
6
|
+
PAGE = 200
|
|
7
|
+
|
|
8
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
9
|
+
def initialize(client:)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Nothing is read until the walk starts, and a page only once the one before it runs out.
|
|
14
|
+
# @yield [Technician] each employee, in the order Housecall Pro lists them.
|
|
15
|
+
def each
|
|
16
|
+
(1..).each do |page|
|
|
17
|
+
body = @client.get 'employees', page: page, page_size: PAGE
|
|
18
|
+
body.fetch('employees').each { |node| yield Technician.new node: node }
|
|
19
|
+
break if page >= body.fetch('total_pages')
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The visits of one location: the appointments Housecall Pro files inside jobs, and the slot
|
|
3
|
+
# it files on each estimate. They are read off the work booked across a window, a page of it
|
|
4
|
+
# at a time, and each kind costs a list of its own.
|
|
5
|
+
class Visits < Company::Visits
|
|
6
|
+
# @param client [Client] how to reach Housecall Pro as the location.
|
|
7
|
+
# @param from [Time, nil] the moment the window opens, or nothing for every visit there was.
|
|
8
|
+
# @param to [Time, nil] the moment the window closes, or nothing for every visit to come.
|
|
9
|
+
# @param technician [Company::Technician, nil] whose work to ask for, or nothing for all.
|
|
10
|
+
# @param jobs [Boolean] whether to read the stops of jobs.
|
|
11
|
+
# @param leads [Boolean] whether to read the stops of estimates.
|
|
12
|
+
def initialize(client:, from: nil, to: nil, technician: nil, jobs: true, leads: true)
|
|
13
|
+
@client = client
|
|
14
|
+
@from = from
|
|
15
|
+
@to = to
|
|
16
|
+
@technician = technician
|
|
17
|
+
@jobs = jobs
|
|
18
|
+
@leads = leads
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param from [Time, nil] the moment the window opens, or nothing for every visit there was.
|
|
22
|
+
# @param to [Time, nil] the moment the window closes, or nothing for every visit to come.
|
|
23
|
+
# @return [Visits] the same list, narrowed to the visits booked to start between the two.
|
|
24
|
+
def between(from, to) = with from: from, to: to
|
|
25
|
+
|
|
26
|
+
# Housecall Pro narrows both lists by who is assigned to the work, so the window is asked
|
|
27
|
+
# for as this technician's; a job's stop is the whole crew's until somebody is dispatched
|
|
28
|
+
# to it, so the stops they are not on are let go once the work comes back.
|
|
29
|
+
# @param technician [Company::Technician] whoever the work is booked for.
|
|
30
|
+
# @return [Company::Selection] the same list, narrowed to the stops they are booked for.
|
|
31
|
+
def assigned_to(technician)
|
|
32
|
+
theirs = with technician: technician
|
|
33
|
+
Company::Selection.new(collection: theirs) do |visit|
|
|
34
|
+
visit.technicians.any? { |each| each.id == technician.id }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Visits] the same list, read off the jobs alone: one request rather than two.
|
|
39
|
+
def for_jobs = with leads: false
|
|
40
|
+
|
|
41
|
+
# @return [Visits] the same list, read off the estimates alone: one request rather than two.
|
|
42
|
+
def for_leads = with jobs: false
|
|
43
|
+
|
|
44
|
+
# Work booked across the window carries every stop in it, so each list is read once and
|
|
45
|
+
# what was called off keeps its stops to itself.
|
|
46
|
+
# @yield [Visit] each visit in the window, the jobs' stops before the estimates'.
|
|
47
|
+
def each(&)
|
|
48
|
+
jobs.each { |job| stops job, & } if @jobs
|
|
49
|
+
estimates.each { |estimate| stops estimate, & } if @leads
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def with(**changed)
|
|
55
|
+
self.class.new(**{ client: @client, from: @from, to: @to, technician: @technician,
|
|
56
|
+
jobs: @jobs, leads: @leads }.merge(changed))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stops(work)
|
|
60
|
+
return if work.canceled?
|
|
61
|
+
|
|
62
|
+
work.visits.each { |visit| yield visit if window.cover? visit.starts_at }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def window = @from..@to
|
|
66
|
+
|
|
67
|
+
def crew = { employee_ids: (@technician && [ @technician.id ]) }
|
|
68
|
+
|
|
69
|
+
def jobs
|
|
70
|
+
bounds = { scheduled_end_min: @from&.utc&.iso8601, scheduled_start_max: @to&.utc&.iso8601 }
|
|
71
|
+
params = bounds.merge(crew).compact.merge expand: [ 'appointments' ]
|
|
72
|
+
Jobs.new client: @client, params: params
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def estimates
|
|
76
|
+
bounds = { scheduled_start_min: @from&.utc&.iso8601, scheduled_start_max: @to&.utc&.iso8601 }
|
|
77
|
+
Estimates.new client: @client, params: bounds.merge(crew).compact
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/hcp/error.rb
CHANGED
data/lib/hcp/event.rb
CHANGED
|
@@ -8,13 +8,10 @@ module Hcp
|
|
|
8
8
|
TIMESTAMP_HEADER = 'Api-Timestamp'
|
|
9
9
|
|
|
10
10
|
# @param params [Hash] the payload for an event webhook.
|
|
11
|
-
def initialize(params
|
|
11
|
+
def initialize(params)
|
|
12
12
|
@params = params
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# @return [Symbol] the type of event, e.g.: :lead_converted, :job_created, :invoice_sent.
|
|
16
|
-
def type = @params.fetch(:event, '').gsub('.', '_').to_sym
|
|
17
|
-
|
|
18
15
|
# @return [String] unique identifier of the lead in a :lead_converted event.
|
|
19
16
|
def lead_id = @params.dig :lead, :id
|
|
20
17
|
|
|
@@ -50,10 +47,8 @@ module Hcp
|
|
|
50
47
|
|
|
51
48
|
private
|
|
52
49
|
|
|
53
|
-
# @return [Hash] the latest conversion, applies to 'lead.converted' events.
|
|
54
50
|
def conversion = @params.dig(:lead, :conversions).last
|
|
55
51
|
|
|
56
|
-
# @return [Symbol] the type of resource affected by the event, can be :lead or :job.
|
|
57
52
|
def resource_type = @params[:event].split('.').first.to_sym
|
|
58
53
|
end
|
|
59
54
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# The business behind a key: the account, or one location of a franchise.
|
|
3
|
+
class Business < Company::Business
|
|
4
|
+
# The node keys Housecall Pro spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { phone: :phone_number }
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def below = records Business, :locations
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -1,52 +1,16 @@
|
|
|
1
1
|
module Hcp
|
|
2
2
|
# Whoever the work is for.
|
|
3
|
-
class Customer <
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
class Customer < Company::Customer
|
|
4
|
+
# The node keys Housecall Pro spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { name: :first_name, surname: :last_name }
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
|
|
7
|
+
# The numbers Housecall Pro holds for a customer, the one they are reached on first.
|
|
8
|
+
NUMBERS = %i[mobile_number home_number work_number]
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# @return [String, nil] first name, or the business's name where a person has none.
|
|
11
|
+
def name = super.presence || attribute(:company).presence
|
|
12
12
|
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# What a customer brings back beside themselves where they are asked to.
|
|
17
|
-
EXPANDS = %i[attachments do_not_service]
|
|
18
|
-
|
|
19
|
-
# Where Housecall Pro keeps them.
|
|
20
|
-
def self.path = 'customers'
|
|
21
|
-
|
|
22
|
-
# What Housecall Pro calls a page of them.
|
|
23
|
-
def self.key = 'customers'
|
|
24
|
-
|
|
25
|
-
attribute :first_name
|
|
26
|
-
attribute :last_name
|
|
27
|
-
attribute :email
|
|
28
|
-
|
|
29
|
-
# @return [String, nil] the business the customer is, where they are one.
|
|
30
|
-
attribute :company
|
|
31
|
-
|
|
32
|
-
# @return [String, nil] where the customer came from.
|
|
33
|
-
attribute :lead_source
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# Housecall Pro holds three numbers, and a caller wants whichever one there is.
|
|
37
|
-
# @return [String, nil] the number the customer is reached on first.
|
|
38
|
-
def phone = @node['mobile_number'] || @node['home_number'] || @node['work_number']
|
|
39
|
-
|
|
40
|
-
# @return [Symbol, nil] :homeowner, or whatever else Housecall Pro takes the customer for.
|
|
41
|
-
def kind = @node['kind']&.to_sym
|
|
42
|
-
|
|
43
|
-
# @return [Boolean] whether the customer agreed to hear from the pro.
|
|
44
|
-
def notifications_enabled? = @node['notifications_enabled']
|
|
45
|
-
|
|
46
|
-
# @return [Array<String>] what the customer is tagged with.
|
|
47
|
-
def tags = Array(@node['tags'])
|
|
48
|
-
|
|
49
|
-
# @return [Relation] every address the customer is billed or served at.
|
|
50
|
-
def addresses = nested Address, 'addresses'
|
|
13
|
+
# @return [String, nil] ten digits they are reached on, nil where none can be dialed.
|
|
14
|
+
def phone = NUMBERS.lazy.filter_map { |number| Company::Phone.from @node[number] }.first
|
|
51
15
|
end
|
|
52
16
|
end
|
|
@@ -1,49 +1,43 @@
|
|
|
1
1
|
module Hcp
|
|
2
|
-
# Work a Housecall Pro user
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# @return [Array<Employee>] the pros the estimate is assigned to.
|
|
44
|
-
def assigned_employees = records Employee, 'assigned_employees'
|
|
45
|
-
|
|
46
|
-
# @return [Array<Estimate::Option>] what the customer was offered, priced.
|
|
47
|
-
def options = records Estimate::Option, 'options'
|
|
2
|
+
# Work a Housecall Pro user was asked to look at and price, which the vocabulary reads as the
|
|
3
|
+
# lead it is: a stop to go and see it, before there is a job. Housecall Pro schedules it the
|
|
4
|
+
# way it schedules a job but hangs no appointments under it, so it holds the one slot.
|
|
5
|
+
class Estimate < Company::Lead
|
|
6
|
+
# @return [Customer, nil] customer the estimate was opened for.
|
|
7
|
+
def customer = record Customer, :customer
|
|
8
|
+
|
|
9
|
+
# Housecall Pro files the address and the customer side by side, the way it does on a job.
|
|
10
|
+
# @return [Location, nil] where the work would happen, nil where it is booked nowhere.
|
|
11
|
+
def location
|
|
12
|
+
address = @node[:address]
|
|
13
|
+
Location.new node: address.merge(customer: @node[:customer]) if address[:id].present?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<Technician>] employees the estimate is assigned to.
|
|
17
|
+
def technicians = records Technician, :assigned_employees
|
|
18
|
+
|
|
19
|
+
# Housecall Pro gives an estimate no words of its own: what it says is said by its options.
|
|
20
|
+
# @return [nil] nothing, so a stop of one goes undescribed.
|
|
21
|
+
def description = nil
|
|
22
|
+
|
|
23
|
+
# Housecall Pro files no ID on the slot, there being only ever the one, so it answers to
|
|
24
|
+
# the estimate's own.
|
|
25
|
+
# @return [Array<Visit>] the one slot the estimate is booked for, empty where it has none.
|
|
26
|
+
def visits
|
|
27
|
+
booked = @node.dig :schedule, :scheduled_start
|
|
28
|
+
return [] if booked.blank?
|
|
29
|
+
|
|
30
|
+
[ Visit.new(node: slot, lead: self) ]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether the customer or the pro called the estimate off.
|
|
34
|
+
def canceled? = attribute(:work_status).to_s.end_with? 'canceled'
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def slot
|
|
39
|
+
{ id: id, start_time: @node.dig(:schedule, :scheduled_start),
|
|
40
|
+
end_time: @node.dig(:schedule, :scheduled_end) }
|
|
41
|
+
end
|
|
48
42
|
end
|
|
49
43
|
end
|
data/lib/hcp/resources/job.rb
CHANGED
|
@@ -1,73 +1,62 @@
|
|
|
1
1
|
module Hcp
|
|
2
2
|
# Work a Housecall Pro user accepted: what it is, who it is for, and what it comes to.
|
|
3
|
-
class Job <
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# @return [Array<Employee>] the pros the job is assigned to.
|
|
62
|
-
def assigned_employees = records Employee, 'assigned_employees'
|
|
63
|
-
|
|
64
|
-
# @return [Relation] the visits the job is booked for.
|
|
65
|
-
def appointments = nested Job::Appointment, 'appointments'
|
|
66
|
-
|
|
67
|
-
# @return [Relation] what the job is billed as.
|
|
68
|
-
def line_items = nested LineItem, 'line_items'
|
|
69
|
-
|
|
70
|
-
# @return [Relation] the invoices raised for the job.
|
|
71
|
-
def invoices = nested Job::Invoice, 'invoices'
|
|
3
|
+
class Job < Company::Job
|
|
4
|
+
# The node keys Housecall Pro spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { amount: :total_amount }
|
|
6
|
+
|
|
7
|
+
# Housecall Pro nests when the work is booked and when it was done, and files the address
|
|
8
|
+
# and the customer side by side; the node is read flat, the way the vocabulary reads one.
|
|
9
|
+
# @param node [Hash] job as Housecall Pro answered it.
|
|
10
|
+
# @param client [Client] how to reach Housecall Pro for what the job holds elsewhere.
|
|
11
|
+
def initialize(node: {}, client:)
|
|
12
|
+
node = node.with_indifferent_access
|
|
13
|
+
super node: node.merge(scheduled_at: node.dig(:schedule, :scheduled_start),
|
|
14
|
+
completed_at: node.dig(:work_timestamps, :completed_at), location: location_from(node))
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Housecall Pro files the notes on a job one by one, which read as a list, one to a line.
|
|
19
|
+
# @return [String, nil] every note's content, `- ` before each, nil where none was written.
|
|
20
|
+
def notes = Array(super).map { |note| "- #{note[:content]}" }.join("\n").presence
|
|
21
|
+
|
|
22
|
+
# Housecall Pro counts in cents, and a caller reads dollars.
|
|
23
|
+
# @return [BigDecimal, nil] what the job comes to.
|
|
24
|
+
def amount = (cents = super) && cents / 100
|
|
25
|
+
|
|
26
|
+
# Housecall Pro files a job's lines under their own endpoint rather than beside the job,
|
|
27
|
+
# so they are read once, on the first ask.
|
|
28
|
+
# @return [Array<Line>] lines the job is billed as.
|
|
29
|
+
def lines
|
|
30
|
+
@lines ||= @client.get("jobs/#{id}/line_items").fetch('data').map { Line.new node: it }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Location, nil] where the work happens, nil where the job is booked nowhere.
|
|
34
|
+
def location = record Location, :location
|
|
35
|
+
|
|
36
|
+
# Housecall Pro nests the appointments under the schedule, and only where the list was
|
|
37
|
+
# asked to bring them.
|
|
38
|
+
# @return [Array<Visit>] stops the job is booked as, empty where none came back.
|
|
39
|
+
def visits = Array(@node.dig :schedule, :appointments).map { Visit.new node: it, job: self }
|
|
40
|
+
|
|
41
|
+
# @return [Array<Technician>] employees the job is assigned to, empty where it is assigned
|
|
42
|
+
# to nobody.
|
|
43
|
+
def technicians = records Technician, :assigned_employees
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] whether the customer or the pro called the job off.
|
|
46
|
+
def canceled? = attribute(:work_status).to_s.end_with? 'canceled'
|
|
47
|
+
|
|
48
|
+
# Housecall Pro names the estimate option a job was created from beside the job, and the
|
|
49
|
+
# option is priced among the customer's estimates, so the quote takes the customer along.
|
|
50
|
+
# @return [Quote, nil] option the job was won with, nil where it was won without one.
|
|
51
|
+
def quote
|
|
52
|
+
option_id = @node[:original_estimate_id]
|
|
53
|
+
Quote.new node: { id: option_id, customer: @node[:customer] }, client: @client if option_id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def location_from(node)
|
|
59
|
+
node[:address].merge customer: node[:customer] if node.dig(:address, :id).present?
|
|
60
|
+
end
|
|
72
61
|
end
|
|
73
62
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# Somebody who asked a Housecall Pro user for work, and where they sit in the pipeline.
|
|
3
|
+
class Lead < Company::Lead
|
|
4
|
+
# @param node [Hash] lead as Housecall Pro answered it.
|
|
5
|
+
# @param client [Client] how to reach Housecall Pro as the lead's location.
|
|
6
|
+
def initialize(node: {}, client:)
|
|
7
|
+
super node: node
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Moves the lead to the status going by this name. Housecall Pro moves a lead by status ID
|
|
12
|
+
# and names them only by their words, so the status is looked up first.
|
|
13
|
+
# @param status_name [String] status as the account names it, such as 'Won'.
|
|
14
|
+
def update(status_name:)
|
|
15
|
+
status = statuses.find { |each| each['name'] == status_name } || unknown(status_name)
|
|
16
|
+
@client.put 'pipeline/statuses',
|
|
17
|
+
resource_type: 'lead', resource_id: id, status_id: status['id']
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def statuses = @client.get('pipeline/statuses', resource_type: 'lead').fetch 'statuses'
|
|
23
|
+
|
|
24
|
+
def unknown(name) = raise Error, "Status #{name} not found for lead #{id}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# A price a Housecall Pro user sent, which Housecall Pro calls an estimate option: a job names
|
|
3
|
+
# the option it was created from, and an estimate holds one option or several.
|
|
4
|
+
class Quote < Company::Quote
|
|
5
|
+
# @param node [Hash] option as Housecall Pro named it beside a job: its ID, and the customer.
|
|
6
|
+
# @param client [Client] how to reach Housecall Pro for the rest of it.
|
|
7
|
+
def initialize(node: {}, client:)
|
|
8
|
+
super node: node
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Housecall Pro reads an estimate by its own ID and not by an option's, so the option is
|
|
13
|
+
# found among the customer's estimates, read once on the first ask.
|
|
14
|
+
# @return [BigDecimal, nil] what the option comes to in dollars, nil where none is found.
|
|
15
|
+
def amount = option && BigDecimal(option['total_amount'].to_s) / 100
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def option = options.find { |each| each['id'] == id }
|
|
20
|
+
|
|
21
|
+
def options = estimates.flat_map { |estimate| estimate['options'] }
|
|
22
|
+
|
|
23
|
+
def estimates
|
|
24
|
+
@estimates ||= @client.get('estimates', customer_id: @node.dig(:customer, :id),
|
|
25
|
+
page_size: Jobs::PAGE).fetch 'estimates'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# A person the business sends out, whom Housecall Pro calls an employee.
|
|
3
|
+
class Technician < Company::Technician
|
|
4
|
+
# The node keys Housecall Pro spells otherwise than the vocabulary.
|
|
5
|
+
def self.keys = { name: :first_name, surname: :last_name }
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Hcp
|
|
2
|
+
# One stop, which Housecall Pro hangs under a job as an appointment and under an estimate as
|
|
3
|
+
# the estimate's own slot: it happens where the work does and says what the work says.
|
|
4
|
+
class Visit < Company::Visit
|
|
5
|
+
# The node keys Housecall Pro spells otherwise than the vocabulary.
|
|
6
|
+
def self.keys = { starts_at: :start_time, ends_at: :end_time }
|
|
7
|
+
|
|
8
|
+
# @param node [Hash] slot as Housecall Pro answered it under the work it belongs to.
|
|
9
|
+
# @param job [Job, nil] job the slot sits under, nil where an estimate does.
|
|
10
|
+
# @param lead [Estimate, nil] estimate the slot sits under, nil where a job does.
|
|
11
|
+
def initialize(node: {}, job: nil, lead: nil)
|
|
12
|
+
super node: node
|
|
13
|
+
@job = job
|
|
14
|
+
@lead = lead
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Job, nil] job the stop belongs to, nil where the stop is an estimate's.
|
|
18
|
+
attr_reader :job
|
|
19
|
+
|
|
20
|
+
# @return [Estimate, nil] estimate the stop belongs to, nil where the stop is a job's.
|
|
21
|
+
attr_reader :lead
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] what the work is called: a stop has no words of its own.
|
|
24
|
+
def description = work.description
|
|
25
|
+
|
|
26
|
+
# An appointment has no address of its own and an estimate's slot is the estimate's, so a
|
|
27
|
+
# stop is where its work is.
|
|
28
|
+
# @return [Location, nil] where the stop happens, nil where the work is booked nowhere.
|
|
29
|
+
def location = work.location
|
|
30
|
+
|
|
31
|
+
# Housecall Pro dispatches a job's stop to some of its crew, or to none of it, and a stop
|
|
32
|
+
# dispatched to nobody is the whole crew's. An estimate dispatches nothing and has one crew.
|
|
33
|
+
# @return [Array<Technician>] whoever the stop is booked for.
|
|
34
|
+
def technicians = dispatched.presence || work.technicians
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def work = @job || @lead
|
|
39
|
+
|
|
40
|
+
def dispatched
|
|
41
|
+
ids = Array attribute(:dispatched_employees_ids)
|
|
42
|
+
work.technicians.select { |technician| ids.include? technician.id }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/hcp/version.rb
CHANGED