your_ai_insight 1.0.7
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 +21 -0
- data/README.md +284 -0
- data/app/controllers/your_ai_insight/application_controller.rb +35 -0
- data/app/controllers/your_ai_insight/chats_controller.rb +26 -0
- data/app/controllers/your_ai_insight/dashboard_controller.rb +25 -0
- data/app/controllers/your_ai_insight/reports_controller.rb +64 -0
- data/app/jobs/your_ai_insight/scheduled_report_job.rb +98 -0
- data/app/mailers/your_ai_insight/ai_report_mailer.rb +80 -0
- data/app/models/your_ai_insight/ai_report.rb +24 -0
- data/app/services/your_ai_insight/claude_service.rb +175 -0
- data/app/services/your_ai_insight/facility_data_service.rb +449 -0
- data/app/views/your_ai_insight/chat/_widget.html.erb +153 -0
- data/app/views/your_ai_insight/dashboard/index.html.erb +818 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20240101000001_create_your_ai_insight_reports.rb +20 -0
- data/lib/tasks/your_ai_insight.rake +63 -0
- data/lib/tasks/your_ai_insight_seed.rake +299 -0
- data/lib/your_ai_insight/configuration.rb +168 -0
- data/lib/your_ai_insight/engine.rb +17 -0
- data/lib/your_ai_insight/version.rb +3 -0
- data/lib/your_ai_insight.rb +3 -0
- metadata +132 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
require "httparty"
|
|
2
|
+
|
|
3
|
+
module YourAiInsight
|
|
4
|
+
class ClaudeService
|
|
5
|
+
include HTTParty
|
|
6
|
+
base_uri "https://api.anthropic.com"
|
|
7
|
+
|
|
8
|
+
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
9
|
+
You are an expert facility management analyst for AllPro IFM.
|
|
10
|
+
You have deep knowledge of construction/facility jobs, subcontractor management,
|
|
11
|
+
vendor bids, budget tracking, compliance, and location-based operations.
|
|
12
|
+
|
|
13
|
+
Terminology used in this system:
|
|
14
|
+
- "Jobs" = facility work orders / projects
|
|
15
|
+
- "Tasks" = line items within a job (each has a vendor/subcontractor, budget, actual cost)
|
|
16
|
+
- "Bids" = vendor quotes submitted for tasks
|
|
17
|
+
- "Budget Requests" = internal approval requests for budget increases
|
|
18
|
+
- "Expenses" = costs logged against jobs or tasks
|
|
19
|
+
- "Location Budgets" = monthly budgets allocated per location by trade/category/area
|
|
20
|
+
- "Locations" = physical facility sites belonging to customers
|
|
21
|
+
- "Customers" = clients who own one or more locations
|
|
22
|
+
- "Vendors" = subcontractors who perform task work
|
|
23
|
+
- "Pay Requests" = payment submissions from vendors after task completion
|
|
24
|
+
|
|
25
|
+
Response guidelines:
|
|
26
|
+
- Lead with the most critical or actionable finding
|
|
27
|
+
- Use markdown: ## headings, **bold** metrics, bullet points
|
|
28
|
+
- For compliance: prefix critical items with ⚠️ CRITICAL, warnings with ⚡ WARNING
|
|
29
|
+
- For dashboards: include an Overall Health Score (0-100) at the top
|
|
30
|
+
- Always end with 3-5 concrete Recommended Actions
|
|
31
|
+
- Be concise — executives read this, not engineers
|
|
32
|
+
PROMPT
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@api_key = YourAiInsight.config.anthropic_api_key ||
|
|
36
|
+
ENV["ANTHROPIC_API_KEY"] ||
|
|
37
|
+
raise(ConfigurationError, "Set YourAiInsight.configure { |c| c.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] }")
|
|
38
|
+
@model = YourAiInsight.config.claude_model
|
|
39
|
+
@max_tokens = YourAiInsight.config.max_tokens
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Free-form chat with optional live data context injected
|
|
43
|
+
def chat(user_message, context: {})
|
|
44
|
+
messages = [{
|
|
45
|
+
role: "user",
|
|
46
|
+
content: [context_block(context), user_message].reject(&:blank?).join("\n\n")
|
|
47
|
+
}]
|
|
48
|
+
call_api(messages)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Structured report generation
|
|
52
|
+
def generate_report(report_type, data)
|
|
53
|
+
messages = [{ role: "user", content: report_prompt(report_type, data) }]
|
|
54
|
+
call_api(messages)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def call_api(messages)
|
|
60
|
+
response = self.class.post(
|
|
61
|
+
"/v1/messages",
|
|
62
|
+
headers: {
|
|
63
|
+
"x-api-key" => @api_key,
|
|
64
|
+
"anthropic-version" => "2023-06-01",
|
|
65
|
+
"content-type" => "application/json"
|
|
66
|
+
},
|
|
67
|
+
body: {
|
|
68
|
+
model: @model,
|
|
69
|
+
max_tokens: @max_tokens,
|
|
70
|
+
system: SYSTEM_PROMPT,
|
|
71
|
+
messages: messages
|
|
72
|
+
}.to_json,
|
|
73
|
+
timeout: 90
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
body = JSON.parse(response.body)
|
|
77
|
+
|
|
78
|
+
unless response.success?
|
|
79
|
+
raise ApiError, "Claude API error (HTTP #{response.code}): #{body.dig('error', 'message')}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
body.dig("content", 0, "text").presence ||
|
|
83
|
+
raise(ApiError, "Empty response from Claude API")
|
|
84
|
+
|
|
85
|
+
rescue HTTParty::Error, Timeout::Error => e
|
|
86
|
+
raise ApiError, "Network error reaching Claude API: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Injects a live snapshot into the chat prompt
|
|
90
|
+
def context_block(ctx)
|
|
91
|
+
return nil if ctx.blank?
|
|
92
|
+
lines = ["## Live Facility Snapshot"]
|
|
93
|
+
lines << "- Active jobs: **#{ctx[:active_jobs]}**" if ctx[:active_jobs]
|
|
94
|
+
lines << "- Jobs on hold: **#{ctx[:jobs_on_hold]}**" if ctx[:jobs_on_hold]
|
|
95
|
+
lines << "- Open tasks: **#{ctx[:open_tasks]}**" if ctx[:open_tasks]
|
|
96
|
+
lines << "- Overdue tasks: **#{ctx[:overdue_tasks]}**" if ctx[:overdue_tasks]
|
|
97
|
+
lines << "- Pending budget requests: **#{ctx[:pending_budget_reqs]}**" if ctx[:pending_budget_reqs]
|
|
98
|
+
lines << "- Open bids awaiting decision: **#{ctx[:open_bids]}**" if ctx[:open_bids]
|
|
99
|
+
lines << "- Total expense budget: **$#{ctx[:total_expense_budget]}**" if ctx[:total_expense_budget]
|
|
100
|
+
lines << "- Total expense actual: **$#{ctx[:total_expense_actual]}**" if ctx[:total_expense_actual]
|
|
101
|
+
lines << "- Locations tracked: **#{ctx[:location_count]}**" if ctx[:location_count]
|
|
102
|
+
lines.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def report_prompt(type, data)
|
|
106
|
+
company = YourAiInsight.config.company_name
|
|
107
|
+
case type.to_sym
|
|
108
|
+
|
|
109
|
+
when :summary_dashboard
|
|
110
|
+
<<~PROMPT
|
|
111
|
+
Generate an executive summary dashboard report for #{company}.
|
|
112
|
+
|
|
113
|
+
## Overall Health Score: [X/100]
|
|
114
|
+
(Score should reflect job completion rate, budget adherence, overdue items, hold status)
|
|
115
|
+
|
|
116
|
+
## Key Metrics at a Glance
|
|
117
|
+
(Most important numbers in a scannable format)
|
|
118
|
+
|
|
119
|
+
## Job Pipeline Status
|
|
120
|
+
(Breakdown by status, priority, holds)
|
|
121
|
+
|
|
122
|
+
## Budget & Cost Performance
|
|
123
|
+
(Budget vs actual across jobs, tasks, expenses; over/under budget analysis)
|
|
124
|
+
|
|
125
|
+
## Vendor & Bid Activity
|
|
126
|
+
(Pending bids, accepted bids, pay requests awaiting approval)
|
|
127
|
+
|
|
128
|
+
## Highlights
|
|
129
|
+
(What's going well)
|
|
130
|
+
|
|
131
|
+
## Concerns
|
|
132
|
+
(What needs attention — flag overdue tasks, jobs on hold, budget overruns)
|
|
133
|
+
|
|
134
|
+
## Top 5 Recommended Actions
|
|
135
|
+
|
|
136
|
+
DATA:
|
|
137
|
+
#{data.to_json}
|
|
138
|
+
PROMPT
|
|
139
|
+
|
|
140
|
+
when :compliance_audit
|
|
141
|
+
<<~PROMPT
|
|
142
|
+
Generate a compliance and audit report for #{company}.
|
|
143
|
+
|
|
144
|
+
## Compliance Status Overview
|
|
145
|
+
|
|
146
|
+
## ⚠️ Critical Items (Immediate Action Required)
|
|
147
|
+
(Jobs/tasks significantly over budget, long-held jobs, unapproved budget requests >30 days old)
|
|
148
|
+
|
|
149
|
+
## ⚡ Warnings (Action Required Within 30 Days)
|
|
150
|
+
(Bids pending decision, tasks near deadline, budget requests pending)
|
|
151
|
+
|
|
152
|
+
## Budget Request Audit
|
|
153
|
+
(Pending approvals, amounts, requestors)
|
|
154
|
+
|
|
155
|
+
## Job Hold Audit
|
|
156
|
+
(All jobs currently on hold with duration)
|
|
157
|
+
|
|
158
|
+
## Vendor Pay Request Audit
|
|
159
|
+
(Outstanding pay requests, amounts, days pending)
|
|
160
|
+
|
|
161
|
+
## Completed This Period
|
|
162
|
+
(Finaled jobs, approved budget requests, paid pay requests)
|
|
163
|
+
|
|
164
|
+
## Recommendations
|
|
165
|
+
|
|
166
|
+
DATA:
|
|
167
|
+
#{data.to_json}
|
|
168
|
+
PROMPT
|
|
169
|
+
|
|
170
|
+
else
|
|
171
|
+
"Analyze this facility management data and provide insights:\n\n#{data.to_json}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
module YourAiInsight
|
|
2
|
+
# Queries the host app's PostgreSQL database using the exact table/column
|
|
3
|
+
# names from portal.allproifm.com schema.rb.
|
|
4
|
+
# All queries use raw SQL via ActiveRecord::Base.connection so no AR models
|
|
5
|
+
# need to exist in the engine itself.
|
|
6
|
+
class FacilityDataService
|
|
7
|
+
def initialize(location_ids: nil, customer_ids: nil, date_range: nil)
|
|
8
|
+
@location_ids = location_ids
|
|
9
|
+
@customer_ids = customer_ids
|
|
10
|
+
@date_range = date_range || (30.days.ago.beginning_of_day..Time.current)
|
|
11
|
+
@c = YourAiInsight.config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# ── Lightweight snapshot used in chat context ────────────────────────────
|
|
15
|
+
def quick_context
|
|
16
|
+
{
|
|
17
|
+
active_jobs: count_jobs(status_not: nil, hold: false),
|
|
18
|
+
jobs_on_hold: count_jobs(hold: true),
|
|
19
|
+
open_tasks: count_tasks(status: 0),
|
|
20
|
+
overdue_tasks: count_overdue_tasks,
|
|
21
|
+
pending_budget_reqs: count_budget_requests(status: 0),
|
|
22
|
+
open_bids: count_open_bids,
|
|
23
|
+
total_expense_budget: sum_expenses(:budget),
|
|
24
|
+
total_expense_actual: sum_expenses(:actual),
|
|
25
|
+
location_count: count_locations
|
|
26
|
+
}
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Rails.logger.warn("[YourAiInsight] quick_context error: #{e.message}")
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ── Full payload for summary dashboard report ────────────────────────────
|
|
33
|
+
def summary_dashboard_data
|
|
34
|
+
{
|
|
35
|
+
period: range_label,
|
|
36
|
+
jobs: job_summary,
|
|
37
|
+
tasks: task_summary,
|
|
38
|
+
bids: bid_summary,
|
|
39
|
+
expenses: expense_summary,
|
|
40
|
+
location_budgets: location_budget_summary,
|
|
41
|
+
budget_requests: budget_request_summary,
|
|
42
|
+
top_locations: top_locations_by_job_count,
|
|
43
|
+
top_customers: top_customers_by_job_count
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ── Full payload for compliance/audit report ─────────────────────────────
|
|
48
|
+
def compliance_audit_data
|
|
49
|
+
{
|
|
50
|
+
period: range_label,
|
|
51
|
+
jobs_on_hold: jobs_on_hold_detail,
|
|
52
|
+
overdue_tasks: overdue_tasks_detail,
|
|
53
|
+
pending_budget_requests: pending_budget_requests_detail,
|
|
54
|
+
open_bids_no_decision: open_bids_detail,
|
|
55
|
+
outstanding_pay_requests: outstanding_pay_requests_detail,
|
|
56
|
+
over_budget_jobs: over_budget_jobs_detail,
|
|
57
|
+
jobs_finaled_this_period: finaled_jobs_count,
|
|
58
|
+
budget_requests_approved: approved_budget_requests_count
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
65
|
+
# JOBS (table: jobs)
|
|
66
|
+
# status integer: 0=new,1=assigned,2=active,3=work_complete,4=all_tasks_complete,5=finaled
|
|
67
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
68
|
+
def job_summary
|
|
69
|
+
{
|
|
70
|
+
total: count_jobs,
|
|
71
|
+
by_status: jobs_by_status,
|
|
72
|
+
by_priority: jobs_by_priority,
|
|
73
|
+
on_hold: count_jobs(hold: true),
|
|
74
|
+
recurring: sql_count("SELECT COUNT(*) FROM #{j} #{job_where} AND recurring = true"),
|
|
75
|
+
accepted_period: sql_count("SELECT COUNT(*) FROM #{j} #{job_where} AND #{@c.jobs_accepted_dt_col} BETWEEN #{range_sql}"),
|
|
76
|
+
finaled_period: sql_count("SELECT COUNT(*) FROM #{j} #{job_where} AND #{@c.jobs_finaled_dt_col} BETWEEN #{range_sql}"),
|
|
77
|
+
total_sales_price: sql_sum("SELECT COALESCE(SUM(#{@c.jobs_sales_price_col}),0) FROM #{j} #{job_where}"),
|
|
78
|
+
total_not_to_exceed:sql_sum("SELECT COALESCE(SUM(#{@c.jobs_not_to_exceed_col}),0) FROM #{j} #{job_where}")
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def jobs_by_status
|
|
83
|
+
rows = exec_query("SELECT #{@c.jobs_status_col}, COUNT(*) as cnt FROM #{j} #{job_where} GROUP BY #{@c.jobs_status_col}")
|
|
84
|
+
labels = { 0 => "new", 1 => "assigned", 2 => "active", 3 => "work_complete", 4 => "all_tasks_complete", 5 => "finaled" }
|
|
85
|
+
rows.each_with_object({}) { |r, h| h[labels[r["status"].to_i] || r["status"].to_s] = r["cnt"].to_i }
|
|
86
|
+
rescue StandardError; {}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def jobs_by_priority
|
|
90
|
+
rows = exec_query("SELECT #{@c.jobs_priority_col}, COUNT(*) as cnt FROM #{j} #{job_where} AND #{@c.jobs_priority_col} IS NOT NULL GROUP BY #{@c.jobs_priority_col}")
|
|
91
|
+
rows.each_with_object({}) { |r, h| h[r["priority"]] = r["cnt"].to_i }
|
|
92
|
+
rescue StandardError; {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def count_jobs(hold: nil, status_not: nil)
|
|
96
|
+
sql = "SELECT COUNT(*) FROM #{j} #{job_where}"
|
|
97
|
+
sql += " AND #{@c.jobs_hold_col} = #{hold}" unless hold.nil?
|
|
98
|
+
sql += " AND #{@c.jobs_status_col} != #{status_not}" unless status_not.nil?
|
|
99
|
+
sql_count(sql)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def count_jobs_simple = sql_count("SELECT COUNT(*) FROM #{j} #{job_where}")
|
|
103
|
+
|
|
104
|
+
def jobs_on_hold_detail
|
|
105
|
+
exec_query(<<~SQL)
|
|
106
|
+
SELECT j.id, j.name, j.#{@c.jobs_priority_col}, j.#{@c.jobs_status_col},
|
|
107
|
+
j.created_at, l.name as location_name, c.name as customer_name
|
|
108
|
+
FROM #{j}
|
|
109
|
+
LEFT JOIN #{loc} l ON l.id = j.#{@c.jobs_location_id_col}
|
|
110
|
+
LEFT JOIN #{cust} c ON c.id = j.#{@c.jobs_customer_id_col}
|
|
111
|
+
#{job_where} AND j.#{@c.jobs_hold_col} = true
|
|
112
|
+
ORDER BY j.created_at ASC LIMIT 25
|
|
113
|
+
SQL
|
|
114
|
+
rescue StandardError; []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def over_budget_jobs_detail
|
|
118
|
+
exec_query(<<~SQL)
|
|
119
|
+
SELECT j.id, j.name,
|
|
120
|
+
COALESCE(SUM(e.#{@c.expenses_actual_col}),0) as total_actual,
|
|
121
|
+
COALESCE(SUM(e.#{@c.expenses_budget_col}),0) as total_budget,
|
|
122
|
+
l.name as location_name
|
|
123
|
+
FROM #{j}
|
|
124
|
+
LEFT JOIN #{exp} e ON e.#{@c.expenses_job_id_col} = j.id
|
|
125
|
+
LEFT JOIN #{loc} l ON l.id = j.#{@c.jobs_location_id_col}
|
|
126
|
+
#{job_where}
|
|
127
|
+
GROUP BY j.id, j.name, l.name
|
|
128
|
+
HAVING COALESCE(SUM(e.#{@c.expenses_actual_col}),0) > COALESCE(SUM(e.#{@c.expenses_budget_col}),0)
|
|
129
|
+
ORDER BY (COALESCE(SUM(e.#{@c.expenses_actual_col}),0) - COALESCE(SUM(e.#{@c.expenses_budget_col}),0)) DESC
|
|
130
|
+
LIMIT 15
|
|
131
|
+
SQL
|
|
132
|
+
rescue StandardError; []
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def finaled_jobs_count
|
|
136
|
+
sql_count("SELECT COUNT(*) FROM #{j} #{job_where} AND #{@c.jobs_finaled_dt_col} BETWEEN #{range_sql}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def top_locations_by_job_count
|
|
140
|
+
exec_query(<<~SQL)
|
|
141
|
+
SELECT l.name, COUNT(j.id) as job_count
|
|
142
|
+
FROM #{loc} l
|
|
143
|
+
LEFT JOIN #{j} ON j.#{@c.jobs_location_id_col} = l.id
|
|
144
|
+
#{location_join_where("l")}
|
|
145
|
+
GROUP BY l.name ORDER BY job_count DESC LIMIT 10
|
|
146
|
+
SQL
|
|
147
|
+
rescue StandardError; []
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def top_customers_by_job_count
|
|
151
|
+
exec_query(<<~SQL)
|
|
152
|
+
SELECT c.name, COUNT(j.id) as job_count
|
|
153
|
+
FROM #{cust} c
|
|
154
|
+
LEFT JOIN #{j} ON j.#{@c.jobs_customer_id_col} = c.id
|
|
155
|
+
#{customer_join_where("c")}
|
|
156
|
+
GROUP BY c.name ORDER BY job_count DESC LIMIT 10
|
|
157
|
+
SQL
|
|
158
|
+
rescue StandardError; []
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
162
|
+
# TASKS (table: tasks)
|
|
163
|
+
# status integer: 0=open, 1=in_progress, 2=complete
|
|
164
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
165
|
+
def task_summary
|
|
166
|
+
{
|
|
167
|
+
total: count_tasks,
|
|
168
|
+
open: count_tasks(status: 0),
|
|
169
|
+
in_progress: count_tasks(status: 1),
|
|
170
|
+
complete: count_tasks(status: 2),
|
|
171
|
+
overdue: count_overdue_tasks,
|
|
172
|
+
total_budget: sql_sum("SELECT COALESCE(SUM(#{@c.tasks_budget_col}),0) FROM #{t} #{task_where}"),
|
|
173
|
+
total_actual: sql_sum("SELECT COALESCE(SUM(#{@c.tasks_actual_col}),0) FROM #{t} #{task_where}"),
|
|
174
|
+
avg_budget: sql_avg("SELECT AVG(#{@c.tasks_budget_col}) FROM #{t} #{task_where}")
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def count_tasks(status: nil)
|
|
179
|
+
sql = "SELECT COUNT(*) FROM #{t} #{task_where}"
|
|
180
|
+
sql += " AND #{@c.tasks_status_col} = #{status}" unless status.nil?
|
|
181
|
+
sql_count(sql)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def count_overdue_tasks
|
|
185
|
+
sql_count(<<~SQL)
|
|
186
|
+
SELECT COUNT(*) FROM #{t} #{task_where}
|
|
187
|
+
AND #{@c.tasks_complete_col} < CURRENT_DATE
|
|
188
|
+
AND #{@c.tasks_status_col} != 2
|
|
189
|
+
SQL
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def overdue_tasks_detail
|
|
193
|
+
exec_query(<<~SQL)
|
|
194
|
+
SELECT t.id, t.name, t.#{@c.tasks_complete_col}, t.#{@c.tasks_budget_col},
|
|
195
|
+
t.#{@c.tasks_actual_col}, j.name as job_name, l.name as location_name
|
|
196
|
+
FROM #{t}
|
|
197
|
+
LEFT JOIN #{j} ON j.id = t.#{@c.tasks_job_id_col}
|
|
198
|
+
LEFT JOIN #{loc} l ON l.id = j.#{@c.jobs_location_id_col}
|
|
199
|
+
#{task_where}
|
|
200
|
+
AND t.#{@c.tasks_complete_col} < CURRENT_DATE
|
|
201
|
+
AND t.#{@c.tasks_status_col} != 2
|
|
202
|
+
ORDER BY t.#{@c.tasks_complete_col} ASC LIMIT 20
|
|
203
|
+
SQL
|
|
204
|
+
rescue StandardError; []
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
208
|
+
# BIDS (table: bids)
|
|
209
|
+
# status: string — "pending", "accepted", "rejected", nil
|
|
210
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
211
|
+
def bid_summary
|
|
212
|
+
{
|
|
213
|
+
total: sql_count("SELECT COUNT(*) FROM #{b} WHERE created_at BETWEEN #{range_sql}"),
|
|
214
|
+
active: sql_count("SELECT COUNT(*) FROM #{b} WHERE active = true AND created_at BETWEEN #{range_sql}"),
|
|
215
|
+
accepted: sql_count("SELECT COUNT(*) FROM #{b} WHERE #{@c.bids_status_col} = 'accepted' AND created_at BETWEEN #{range_sql}"),
|
|
216
|
+
pending: count_open_bids,
|
|
217
|
+
total_bid_amount: sql_sum("SELECT COALESCE(SUM(#{@c.bids_amount_col}),0) FROM #{b} WHERE created_at BETWEEN #{range_sql}"),
|
|
218
|
+
total_actual: sql_sum("SELECT COALESCE(SUM(#{@c.bids_actual_col}),0) FROM #{b} WHERE created_at BETWEEN #{range_sql}")
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def count_open_bids
|
|
223
|
+
sql_count("SELECT COUNT(*) FROM #{b} WHERE (#{@c.bids_status_col} IS NULL OR #{@c.bids_status_col} NOT IN ('accepted','rejected')) AND created_at BETWEEN #{range_sql}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def open_bids_detail
|
|
227
|
+
exec_query(<<~SQL)
|
|
228
|
+
SELECT b.id, b.#{@c.bids_amount_col}, b.received, b.notes,
|
|
229
|
+
t.name as task_name, j.name as job_name, v.name as vendor_name
|
|
230
|
+
FROM #{b}
|
|
231
|
+
LEFT JOIN #{t} ON #{t}.id = b.#{@c.bids_task_id_col}
|
|
232
|
+
LEFT JOIN #{j} ON #{j}.id = #{t}.#{@c.tasks_job_id_col}
|
|
233
|
+
LEFT JOIN vendors v ON v.id = b.vendor_id
|
|
234
|
+
WHERE (b.#{@c.bids_status_col} IS NULL OR b.#{@c.bids_status_col} NOT IN ('accepted','rejected'))
|
|
235
|
+
AND b.created_at BETWEEN #{range_sql}
|
|
236
|
+
ORDER BY b.received ASC LIMIT 20
|
|
237
|
+
SQL
|
|
238
|
+
rescue StandardError; []
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
242
|
+
# EXPENSES (table: expenses)
|
|
243
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
244
|
+
def expense_summary
|
|
245
|
+
{
|
|
246
|
+
total_budget: sum_expenses(:budget),
|
|
247
|
+
total_actual: sum_expenses(:actual),
|
|
248
|
+
over_budget_count: sql_count(<<~SQL),
|
|
249
|
+
SELECT COUNT(*) FROM #{exp} WHERE #{@c.expenses_actual_col} > #{@c.expenses_budget_col}
|
|
250
|
+
AND created_at BETWEEN #{range_sql}
|
|
251
|
+
SQL
|
|
252
|
+
by_type: exec_query(<<~SQL)
|
|
253
|
+
SELECT et.name, COUNT(e.id) as cnt, COALESCE(SUM(e.#{@c.expenses_actual_col}),0) as total
|
|
254
|
+
FROM #{exp} e
|
|
255
|
+
LEFT JOIN expense_types et ON et.id = e.expense_type_id
|
|
256
|
+
WHERE e.created_at BETWEEN #{range_sql}
|
|
257
|
+
GROUP BY et.name ORDER BY total DESC
|
|
258
|
+
SQL
|
|
259
|
+
}
|
|
260
|
+
rescue StandardError; {}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def sum_expenses(col)
|
|
264
|
+
actual_col = col == :budget ? @c.expenses_budget_col : @c.expenses_actual_col
|
|
265
|
+
sql_sum("SELECT COALESCE(SUM(#{actual_col}),0) FROM #{exp} WHERE created_at BETWEEN #{range_sql}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
269
|
+
# LOCATION BUDGETS (table: location_budgets)
|
|
270
|
+
# Monthly columns: month1..month12
|
|
271
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
272
|
+
def location_budget_summary
|
|
273
|
+
year = Date.today.year
|
|
274
|
+
month_cols = (1..12).map { |m| "COALESCE(month#{m},0)" }.join(" + ")
|
|
275
|
+
|
|
276
|
+
exec_query(<<~SQL)
|
|
277
|
+
SELECT l.name as location_name,
|
|
278
|
+
(#{month_cols}) as annual_budget,
|
|
279
|
+
lb.fiscal_year, lb.trade_id, lb.category_id
|
|
280
|
+
FROM #{lb} lb
|
|
281
|
+
LEFT JOIN #{loc} l ON l.id = lb.#{@c.lb_location_id_col}
|
|
282
|
+
WHERE lb.#{@c.lb_fiscal_year_col} = #{year}
|
|
283
|
+
#{location_id_filter("lb")}
|
|
284
|
+
ORDER BY annual_budget DESC LIMIT 20
|
|
285
|
+
SQL
|
|
286
|
+
rescue StandardError; []
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
290
|
+
# BUDGET REQUESTS (table: budget_requests)
|
|
291
|
+
# status integer: 0=pending, 1=approved, 2=rejected
|
|
292
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
293
|
+
def budget_request_summary
|
|
294
|
+
{
|
|
295
|
+
total: sql_count("SELECT COUNT(*) FROM #{br} WHERE created_at BETWEEN #{range_sql}"),
|
|
296
|
+
pending: count_budget_requests(status: 0),
|
|
297
|
+
approved: count_budget_requests(status: 1),
|
|
298
|
+
rejected: count_budget_requests(status: 2),
|
|
299
|
+
total_amount_pending: sql_sum("SELECT COALESCE(SUM(#{@c.br_amount_col}),0) FROM #{br} WHERE #{@c.br_status_col} = 0 AND created_at BETWEEN #{range_sql}")
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def count_budget_requests(status:)
|
|
304
|
+
sql_count("SELECT COUNT(*) FROM #{br} WHERE #{@c.br_status_col} = #{status} AND created_at BETWEEN #{range_sql}")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def approved_budget_requests_count
|
|
308
|
+
count_budget_requests(status: 1)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def pending_budget_requests_detail
|
|
312
|
+
exec_query(<<~SQL)
|
|
313
|
+
SELECT br.id, br.#{@c.br_amount_col}, br.created_at, br.notes,
|
|
314
|
+
t.name as task_name, j.name as job_name,
|
|
315
|
+
u.first_name || ' ' || u.last_name as requestor
|
|
316
|
+
FROM #{br}
|
|
317
|
+
LEFT JOIN tasks t ON t.id = br.#{@c.br_task_id_col}
|
|
318
|
+
LEFT JOIN jobs j ON j.id = t.job_id
|
|
319
|
+
LEFT JOIN users u ON u.id = br.requestor_id
|
|
320
|
+
WHERE br.#{@c.br_status_col} = 0
|
|
321
|
+
AND br.created_at BETWEEN #{range_sql}
|
|
322
|
+
ORDER BY br.created_at ASC LIMIT 20
|
|
323
|
+
SQL
|
|
324
|
+
rescue StandardError; []
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
328
|
+
# PAY REQUESTS (table: pay_requests)
|
|
329
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
330
|
+
def outstanding_pay_requests_detail
|
|
331
|
+
exec_query(<<~SQL)
|
|
332
|
+
SELECT pr.id, pr.amount, pr.submitted, pr.notes, pr.ref,
|
|
333
|
+
t.name as task_name, j.name as job_name,
|
|
334
|
+
v.name as vendor_name
|
|
335
|
+
FROM pay_requests pr
|
|
336
|
+
LEFT JOIN tasks t ON t.id = pr.task_id
|
|
337
|
+
LEFT JOIN jobs j ON j.id = t.job_id
|
|
338
|
+
LEFT JOIN vendors v ON v.id = pr.vendor_id
|
|
339
|
+
WHERE pr.paid IS NULL AND pr.declined IS NULL
|
|
340
|
+
AND pr.submitted BETWEEN #{range_sql}
|
|
341
|
+
ORDER BY pr.submitted ASC LIMIT 20
|
|
342
|
+
SQL
|
|
343
|
+
rescue StandardError; []
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
347
|
+
# LOCATIONS & CUSTOMERS
|
|
348
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
349
|
+
def count_locations
|
|
350
|
+
sql_count("SELECT COUNT(*) FROM #{loc} #{location_where}")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
354
|
+
# SQL HELPERS
|
|
355
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
356
|
+
|
|
357
|
+
# Table name shortcuts
|
|
358
|
+
def j = @c.jobs_table
|
|
359
|
+
def t = @c.tasks_table
|
|
360
|
+
def b = @c.bids_table
|
|
361
|
+
def exp = @c.expenses_table
|
|
362
|
+
def lb = @c.location_budgets_table
|
|
363
|
+
def loc = @c.locations_table
|
|
364
|
+
def cust= @c.customers_table
|
|
365
|
+
def br = @c.budget_requests_table
|
|
366
|
+
|
|
367
|
+
def range_sql
|
|
368
|
+
"'#{@date_range.first.to_s(:db)}' AND '#{@date_range.last.to_s(:db)}'"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def range_label
|
|
372
|
+
{ from: @date_range.first.to_s, to: @date_range.last.to_s }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# WHERE clause for jobs table (scoped by location/customer)
|
|
376
|
+
def job_where
|
|
377
|
+
parts = ["WHERE #{j}.created_at BETWEEN #{range_sql}"]
|
|
378
|
+
if @location_ids.present?
|
|
379
|
+
parts << "AND #{j}.#{@c.jobs_location_id_col} IN (#{safe_ids(@location_ids)})"
|
|
380
|
+
elsif @customer_ids.present?
|
|
381
|
+
parts << "AND #{j}.#{@c.jobs_customer_id_col} IN (#{safe_ids(@customer_ids)})"
|
|
382
|
+
end
|
|
383
|
+
parts.join(" ")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# WHERE clause for tasks (joined via jobs)
|
|
387
|
+
def task_where
|
|
388
|
+
parts = ["WHERE #{t}.created_at BETWEEN #{range_sql}"]
|
|
389
|
+
if @location_ids.present?
|
|
390
|
+
parts << "AND #{t}.#{@c.tasks_job_id_col} IN (SELECT id FROM #{j} WHERE #{@c.jobs_location_id_col} IN (#{safe_ids(@location_ids)}))"
|
|
391
|
+
elsif @customer_ids.present?
|
|
392
|
+
parts << "AND #{t}.#{@c.tasks_job_id_col} IN (SELECT id FROM #{j} WHERE #{@c.jobs_customer_id_col} IN (#{safe_ids(@customer_ids)}))"
|
|
393
|
+
end
|
|
394
|
+
parts.join(" ")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# WHERE clause for locations table
|
|
398
|
+
def location_where
|
|
399
|
+
return "" unless @location_ids.present?
|
|
400
|
+
"WHERE id IN (#{safe_ids(@location_ids)})"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# JOIN WHERE clause when locations table is aliased
|
|
404
|
+
def location_join_where(alias_name = "l")
|
|
405
|
+
return "" unless @location_ids.present?
|
|
406
|
+
"WHERE #{alias_name}.id IN (#{safe_ids(@location_ids)})"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def customer_join_where(alias_name = "c")
|
|
410
|
+
return "" unless @customer_ids.present?
|
|
411
|
+
"WHERE #{alias_name}.id IN (#{safe_ids(@customer_ids)})"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def location_id_filter(alias_name = nil)
|
|
415
|
+
return "" unless @location_ids.present?
|
|
416
|
+
col = alias_name ? "#{alias_name}.#{@c.lb_location_id_col}" : @c.lb_location_id_col
|
|
417
|
+
"AND #{col} IN (#{safe_ids(@location_ids)})"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def safe_ids(ids)
|
|
421
|
+
ids.map(&:to_i).join(",")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def exec_query(sql)
|
|
425
|
+
ActiveRecord::Base.connection.exec_query(sql).to_a
|
|
426
|
+
rescue StandardError => e
|
|
427
|
+
Rails.logger.warn("[YourAiInsight::FacilityDataService] Query error: #{e.message}")
|
|
428
|
+
[]
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def sql_count(sql)
|
|
432
|
+
ActiveRecord::Base.connection.execute(sql).first.values.first.to_i
|
|
433
|
+
rescue StandardError
|
|
434
|
+
0
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def sql_sum(sql)
|
|
438
|
+
ActiveRecord::Base.connection.execute(sql).first.values.first.to_f.round(2)
|
|
439
|
+
rescue StandardError
|
|
440
|
+
0.0
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def sql_avg(sql)
|
|
444
|
+
ActiveRecord::Base.connection.execute(sql).first.values.first&.to_f&.round(2)
|
|
445
|
+
rescue StandardError
|
|
446
|
+
nil
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|