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.
@@ -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