fin_it 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +24 -0
  3. data/CHANGELOG.md +9 -0
  4. data/CONTRIBUTING.md +20 -0
  5. data/LICENSE +21 -0
  6. data/QUICKSTART.md +56 -0
  7. data/README.md +74 -0
  8. data/Rakefile +23 -0
  9. data/SECURITY.md +14 -0
  10. data/assets/fin_it_logo.png +0 -0
  11. data/lib/fin_it/account.rb +120 -0
  12. data/lib/fin_it/calculator/currency_conversion.rb +27 -0
  13. data/lib/fin_it/calculator/date_helpers.rb +53 -0
  14. data/lib/fin_it/calculator/variable_hashing.rb +120 -0
  15. data/lib/fin_it/calculator.rb +480 -0
  16. data/lib/fin_it/categories/category.rb +137 -0
  17. data/lib/fin_it/complex_model.rb +169 -0
  18. data/lib/fin_it/dsl/account_builder.rb +35 -0
  19. data/lib/fin_it/dsl/calculated_builder.rb +87 -0
  20. data/lib/fin_it/dsl/config_builder.rb +58 -0
  21. data/lib/fin_it/dsl/model_builder.rb +938 -0
  22. data/lib/fin_it/dsl/model_template_builder.rb +29 -0
  23. data/lib/fin_it/dsl/plan_builder.rb +52 -0
  24. data/lib/fin_it/dsl/project_inheritance_resolver.rb +46 -0
  25. data/lib/fin_it/dsl/variable_builder.rb +41 -0
  26. data/lib/fin_it/dsl.rb +13 -0
  27. data/lib/fin_it/engine.rb +15 -0
  28. data/lib/fin_it/financial_model/account_balances.rb +99 -0
  29. data/lib/fin_it/financial_model/account_hierarchy.rb +158 -0
  30. data/lib/fin_it/financial_model/category_values.rb +179 -0
  31. data/lib/fin_it/financial_model/currency_helpers.rb +14 -0
  32. data/lib/fin_it/financial_model/date_helpers.rb +58 -0
  33. data/lib/fin_it/financial_model/debugging.rb +353 -0
  34. data/lib/fin_it/financial_model/period_flows.rb +121 -0
  35. data/lib/fin_it/financial_model/validation.rb +85 -0
  36. data/lib/fin_it/financial_model/variable_matching.rb +49 -0
  37. data/lib/fin_it/financial_model.rb +395 -0
  38. data/lib/fin_it/model_template.rb +121 -0
  39. data/lib/fin_it/outputs/base_output.rb +51 -0
  40. data/lib/fin_it/outputs/console_output.rb +1528 -0
  41. data/lib/fin_it/outputs/monthly_console_output.rb +145 -0
  42. data/lib/fin_it/outputs/zaxcel_output.rb +1264 -0
  43. data/lib/fin_it/payment_schedule.rb +112 -0
  44. data/lib/fin_it/plan.rb +159 -0
  45. data/lib/fin_it/reports/balance_sheet.rb +638 -0
  46. data/lib/fin_it/reports/base_report.rb +239 -0
  47. data/lib/fin_it/reports/cash_flow_statement.rb +480 -0
  48. data/lib/fin_it/reports/custom_sheet.rb +436 -0
  49. data/lib/fin_it/reports/income_statement.rb +793 -0
  50. data/lib/fin_it/reports/period_comparison.rb +309 -0
  51. data/lib/fin_it/reports/scenario_comparison.rb +296 -0
  52. data/lib/fin_it/temporal_value.rb +349 -0
  53. data/lib/fin_it/transaction_generator/account_resolver.rb +118 -0
  54. data/lib/fin_it/transaction_generator/cache_management.rb +39 -0
  55. data/lib/fin_it/transaction_generator/date_generation.rb +57 -0
  56. data/lib/fin_it/transaction_generator.rb +357 -0
  57. data/lib/fin_it/version.rb +6 -0
  58. data/lib/fin_it.rb +27 -0
  59. data/test/fin_it/calculator_test.rb +109 -0
  60. data/test/fin_it/complex_model_test.rb +198 -0
  61. data/test/fin_it/debugging_test.rb +112 -0
  62. data/test/fin_it/driver_variables_test.rb +109 -0
  63. data/test/fin_it/dsl_test.rb +581 -0
  64. data/test/fin_it/financial_model_test.rb +196 -0
  65. data/test/fin_it/frequency_test.rb +51 -0
  66. data/test/fin_it/outputs/console_output_test.rb +249 -0
  67. data/test/fin_it/plan_test.rb +281 -0
  68. data/test/fin_it/reports/account_balance_test.rb +232 -0
  69. data/test/fin_it/reports/balance_sheet_test.rb +355 -0
  70. data/test/fin_it/reports/cash_flow_statement_test.rb +234 -0
  71. data/test/fin_it/reports/custom_sheet_test.rb +246 -0
  72. data/test/fin_it/reports/income_statement_test.rb +431 -0
  73. data/test/fin_it/reports/period_comparison_test.rb +226 -0
  74. data/test/fin_it/reports/restaurant_model_test.rb +225 -0
  75. data/test/fin_it/reports/scenario_comparison_test.rb +414 -0
  76. data/test/scripts/generate_demo_reports.rb +47 -0
  77. data/test/scripts/startup_saas_demo.rb +62 -0
  78. data/test/test_helper.rb +25 -0
  79. data/test/verify_accounting_equation.rb +91 -0
  80. metadata +264 -0
@@ -0,0 +1,1264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zaxcel'
4
+
5
+ module FinIt
6
+ module Outputs
7
+ class ZaxcelOutput < BaseOutput
8
+ def generate
9
+ @document = Zaxcel::Document.new
10
+ setup_styles
11
+
12
+ # Support multiple reports in one call
13
+ # Each report can be:
14
+ # - A report object directly
15
+ # - A hash with { report:, periods:, sheet_name: }
16
+ reports_to_process = options[:reports] || [@report]
17
+
18
+ reports_to_process.each do |report_data|
19
+ if report_data.is_a?(Hash) && report_data[:report]
20
+ @report = report_data[:report]
21
+ @current_sheet_name = report_data[:sheet_name]
22
+ original_periods = options[:periods]
23
+ options[:periods] = report_data[:periods] if report_data.key?(:periods)
24
+ process_report
25
+ options[:periods] = original_periods
26
+ @current_sheet_name = nil
27
+ else
28
+ @report = report_data
29
+ @current_sheet_name = nil
30
+ process_report
31
+ end
32
+ end
33
+
34
+ filename = options[:filename] || "report_#{@report.output_currency}.xlsx"
35
+ File.binwrite(filename, @document.file_contents)
36
+ filename
37
+ end
38
+
39
+ private
40
+
41
+ def setup_styles
42
+ # Required base styles for zaxcel
43
+ @document.add_style!(:default_cell)
44
+ @document.add_style!(:row_style)
45
+ @document.add_style!(:header_style, bg_color: 'E0E0E0', b: true)
46
+
47
+ # Custom styles for reports
48
+ @document.add_style!(:section_header, bg_color: 'F0F0F0', b: true)
49
+ @document.add_style!(:subtotal, bg_color: 'F5F5F5', b: true)
50
+ @document.add_style!(:total, bg_color: 'D0D0D0', b: true)
51
+ @document.add_style!(:currency, format_code: currency_format_code(@report.output_currency))
52
+ @document.add_style!(:currency_bold, format_code: currency_format_code(@report.output_currency), b: true)
53
+ @document.add_style!(:currency_total, format_code: currency_format_code(@report.output_currency), bg_color: 'D0D0D0', b: true)
54
+ @document.add_style!(:percent, format_code: '0.00%')
55
+ end
56
+
57
+ def currency_format_code(currency)
58
+ case currency
59
+ when 'USD' then '$#,##0.00'
60
+ when 'EUR' then '€#,##0.00'
61
+ when 'MXN' then 'MXN$#,##0.00'
62
+ when 'GBP' then '£#,##0.00'
63
+ else '#,##0.00'
64
+ end
65
+ end
66
+
67
+ def process_report
68
+ data = @report.generate
69
+ report_type = data[:report_type] || (data[:metadata] ? data[:metadata][:report_type] : nil)
70
+
71
+ # Handle special report types
72
+ case report_type
73
+ when "ScenarioComparison"
74
+ sheet_name = @current_sheet_name || "Scenario Comparison"
75
+ add_scenario_comparison(sheet_name, data)
76
+ return
77
+ when "PeriodComparison"
78
+ sheet_name = @current_sheet_name || "Period Comparison"
79
+ add_period_comparison(sheet_name, data)
80
+ return
81
+ when "CustomSheet"
82
+ sheet_name = @current_sheet_name || data[:title] || "Custom"
83
+ add_custom_sheet(sheet_name, data)
84
+ return
85
+ end
86
+
87
+ # Check if we have multiple periods to display as columns
88
+ periods_data = options[:periods]
89
+ if periods_data
90
+ periods_array = extract_periods_array(periods_data)
91
+
92
+ if periods_array && periods_array.length > 1
93
+ sheet_name = @current_sheet_name || generate_sheet_name(report_type, data[:project], 'Monthly')
94
+ case report_type
95
+ when "BalanceSheet"
96
+ add_balance_sheet_comparison(sheet_name, periods_array)
97
+ when "CashFlowStatement"
98
+ add_cash_flow_statement_comparison(sheet_name, periods_array, data)
99
+ else
100
+ add_income_statement_comparison(sheet_name, periods_array, data)
101
+ end
102
+ return
103
+ end
104
+ end
105
+
106
+ # Single period report
107
+ sheet_name = @current_sheet_name || generate_sheet_name(report_type, data[:project], nil)
108
+ case report_type
109
+ when "BalanceSheet"
110
+ add_balance_sheet(sheet_name, data)
111
+ when "CashFlowStatement"
112
+ add_cash_flow_statement(sheet_name, data)
113
+ else
114
+ add_income_statement(sheet_name, data)
115
+ end
116
+ end
117
+
118
+ def extract_periods_array(periods_data)
119
+ if periods_data.is_a?(Hash) && periods_data[:periods]
120
+ periods_data[:periods]
121
+ elsif periods_data.is_a?(Array)
122
+ periods_data
123
+ end
124
+ end
125
+
126
+ def generate_sheet_name(report_type, project, suffix)
127
+ parts = [report_type]
128
+ parts << project.to_s.split('_').map(&:capitalize).join(' ') if project
129
+ parts << suffix if suffix
130
+ name = parts.join(' - ')
131
+ name.length > 31 ? name[0..27] + '...' : name
132
+ end
133
+
134
+ # ==========================================
135
+ # Income Statement - Period Comparison
136
+ # ==========================================
137
+
138
+ def add_income_statement_comparison(sheet_name, periods, report_data)
139
+ sheet = @document.add_sheet!(sheet_name)
140
+ separate_cogs_mode = report_data[:expense_display_mode] == :separate_cogs
141
+ display_items = report_data[:display_items] || []
142
+
143
+ # Define columns: label + one per period
144
+ sheet.add_column!(:label, width: 50, row_style: :default_cell)
145
+ periods.each_with_index do |period_data, idx|
146
+ period = period_data[:period] || period_data[:report][:period]
147
+ header = period[:start].strftime('%b %Y')
148
+ sheet.add_column!(:"period_#{idx}", header: header, width: 15, row_style: :currency)
149
+ end
150
+
151
+ # Header row
152
+ sheet.add_column_header_row!
153
+
154
+ # Track row references for formulas
155
+ @row_refs = {}
156
+ row_counter = 0
157
+
158
+ # INCOME section
159
+ add_section_header(sheet, "INCOME", row_counter)
160
+ row_counter += 1
161
+
162
+ income_items = collect_items_from_periods(periods, :income)
163
+ row_counter = add_period_items(sheet, income_items, periods, row_counter, 0)
164
+
165
+ # TOTAL INCOME with SUM formula
166
+ total_income_row = sheet.add_row!(:"total_income_#{row_counter}")
167
+ total_income_row.add!(:label, value: 'TOTAL INCOME', style: :subtotal)
168
+ periods.each_with_index do |period_data, idx|
169
+ col = :"period_#{idx}"
170
+ report = period_data[:report] || period_data
171
+ income_total = report[:sections][:income][:total] rescue 0
172
+ total_income_row.add!(col, value: income_total, style: :currency_bold)
173
+ end
174
+ @row_refs[:total_income] = total_income_row
175
+ row_counter += 1
176
+
177
+ # Add empty row
178
+ sheet.add_empty_row!
179
+ row_counter += 1
180
+
181
+ # COGS section if separate_cogs mode
182
+ if separate_cogs_mode
183
+ cogs_items = collect_items_from_periods(periods, :cogs)
184
+ if cogs_items.any?
185
+ add_section_header(sheet, "COST OF GOODS SOLD", row_counter)
186
+ row_counter += 1
187
+
188
+ row_counter = add_period_items(sheet, cogs_items, periods, row_counter, 0)
189
+
190
+ # TOTAL COGS
191
+ total_cogs_row = sheet.add_row!(:"total_cogs_#{row_counter}")
192
+ total_cogs_row.add!(:label, value: 'TOTAL COST OF GOODS SOLD', style: :subtotal)
193
+ periods.each_with_index do |period_data, idx|
194
+ col = :"period_#{idx}"
195
+ report = period_data[:report] || period_data
196
+ cogs_total = report[:sections][:cogs][:total] rescue 0
197
+ total_cogs_row.add!(col, value: cogs_total, style: :currency_bold)
198
+ end
199
+ @row_refs[:total_cogs] = total_cogs_row
200
+ row_counter += 1
201
+
202
+ # GROSS MARGIN with formula: TOTAL INCOME - TOTAL COGS
203
+ gross_margin_row = sheet.add_row!(:"gross_margin_#{row_counter}")
204
+ gross_margin_row.add!(:label, value: 'GROSS MARGIN', style: :total)
205
+ periods.each_with_index do |_, idx|
206
+ col = :"period_#{idx}"
207
+ income_ref = @row_refs[:total_income].ref(col)
208
+ cogs_ref = @row_refs[:total_cogs].ref(col)
209
+ gross_margin_row.add!(col, value: income_ref - cogs_ref, style: :currency_total)
210
+ end
211
+ @row_refs[:gross_margin] = gross_margin_row
212
+ row_counter += 1
213
+
214
+ sheet.add_empty_row!
215
+ row_counter += 1
216
+ end
217
+ end
218
+
219
+ # OPERATING EXPENSES section
220
+ section_label = separate_cogs_mode ? "OPERATING EXPENSES" : "EXPENSES"
221
+ add_section_header(sheet, section_label, row_counter)
222
+ row_counter += 1
223
+
224
+ expense_items = collect_items_from_periods(periods, :expenses)
225
+ row_counter = add_period_items(sheet, expense_items, periods, row_counter, 0)
226
+
227
+ # TOTAL EXPENSES
228
+ expense_total_label = separate_cogs_mode ? "TOTAL OPERATING EXPENSES" : "TOTAL EXPENSES"
229
+ total_expenses_row = sheet.add_row!(:"total_expenses_#{row_counter}")
230
+ total_expenses_row.add!(:label, value: expense_total_label, style: :subtotal)
231
+ periods.each_with_index do |period_data, idx|
232
+ col = :"period_#{idx}"
233
+ report = period_data[:report] || period_data
234
+ expense_total = report[:sections][:expenses][:total] rescue 0
235
+ total_expenses_row.add!(col, value: expense_total, style: :currency_bold)
236
+ end
237
+ @row_refs[:total_expenses] = total_expenses_row
238
+ row_counter += 1
239
+
240
+ sheet.add_empty_row!
241
+ row_counter += 1
242
+
243
+ # NET INCOME with formula: TOTAL INCOME - TOTAL EXPENSES (or GROSS MARGIN - TOTAL EXPENSES)
244
+ net_income_row = sheet.add_row!(:"net_income_#{row_counter}")
245
+ net_income_row.add!(:label, value: 'NET INCOME', style: :total)
246
+ periods.each_with_index do |_, idx|
247
+ col = :"period_#{idx}"
248
+ if separate_cogs_mode && @row_refs[:gross_margin]
249
+ gross_margin_ref = @row_refs[:gross_margin].ref(col)
250
+ expenses_ref = @row_refs[:total_expenses].ref(col)
251
+ net_income_row.add!(col, value: gross_margin_ref - expenses_ref, style: :currency_total)
252
+ else
253
+ income_ref = @row_refs[:total_income].ref(col)
254
+ expenses_ref = @row_refs[:total_expenses].ref(col)
255
+ net_income_row.add!(col, value: income_ref - expenses_ref, style: :currency_total)
256
+ end
257
+ end
258
+ @row_refs[:net_income] = net_income_row
259
+ row_counter += 1
260
+
261
+ # Add display items (like Net Margin %)
262
+ display_items.select { |di| di[:after] == :net_income }.each do |item|
263
+ display_row = sheet.add_row!(:"display_#{item[:name]}_#{row_counter}")
264
+ display_row.add!(:label, value: item[:label])
265
+
266
+ periods.each_with_index do |period_data, idx|
267
+ col = :"period_#{idx}"
268
+ report = period_data[:report] || period_data
269
+ period_items = report[:display_items] || []
270
+ period_item = period_items.find { |di| di[:name] == item[:name] }
271
+ value = period_item ? period_item[:value] : 0
272
+
273
+ style = item[:format] == :percentage ? :percent : :currency
274
+ formatted_value = item[:format] == :percentage ? value / 100.0 : value
275
+ display_row.add!(col, value: formatted_value, style: style)
276
+ end
277
+ row_counter += 1
278
+ end
279
+
280
+ # Position and generate
281
+ sheet.position_rows!
282
+ sheet.generate_sheet!
283
+ end
284
+
285
+ def collect_items_from_periods(periods, section_key)
286
+ items = {}
287
+ periods.each_with_index do |period_data, idx|
288
+ report = period_data[:report] || period_data
289
+ section = report[:sections][section_key] rescue nil
290
+ next unless section && section[:items]
291
+
292
+ section[:items].each do |item|
293
+ collect_item_recursive(items, item, idx)
294
+ end
295
+ end
296
+ items
297
+ end
298
+
299
+ def collect_item_recursive(items, item, period_idx)
300
+ key = item[:name] || item[:display_name]
301
+ display_name = item[:display_name] || humanize_name(key)
302
+ indent = item[:indent] || 0
303
+
304
+ items[key] ||= {
305
+ name: key,
306
+ display_name: display_name,
307
+ indent: indent,
308
+ values: {},
309
+ subcategories: {}
310
+ }
311
+
312
+ items[key][:values][period_idx] = item[:value] || 0
313
+
314
+ if item[:subcategories]
315
+ item[:subcategories].each do |sub|
316
+ collect_item_recursive(items[key][:subcategories], sub, period_idx)
317
+ end
318
+ end
319
+ end
320
+
321
+ def add_period_items(sheet, items, periods, row_counter, indent_level)
322
+ items.each do |key, item_data|
323
+ indent = " " * (indent_level + item_data[:indent])
324
+ row_name = :"item_#{key}_#{row_counter}"
325
+ row = sheet.add_row!(row_name)
326
+ row.add!(:label, value: "#{indent}#{item_data[:display_name]}")
327
+
328
+ periods.each_with_index do |_, idx|
329
+ col = :"period_#{idx}"
330
+ value = item_data[:values][idx] || 0
331
+ row.add!(col, value: value, style: :currency)
332
+ end
333
+
334
+ row_counter += 1
335
+
336
+ # Add subcategories
337
+ if item_data[:subcategories] && item_data[:subcategories].any?
338
+ row_counter = add_period_items(sheet, item_data[:subcategories], periods, row_counter, indent_level + 1)
339
+ end
340
+ end
341
+
342
+ row_counter
343
+ end
344
+
345
+ def add_section_header(sheet, label, row_counter)
346
+ row = sheet.add_row!(:"section_#{label.downcase.gsub(' ', '_')}_#{row_counter}")
347
+ row.add!(:label, value: label, style: :section_header)
348
+ end
349
+
350
+ # ==========================================
351
+ # Single Period Income Statement
352
+ # ==========================================
353
+
354
+ def add_income_statement(sheet_name, data)
355
+ sheet = @document.add_sheet!(sheet_name)
356
+
357
+ sheet.add_column!(:label, width: 50, row_style: :default_cell)
358
+ sheet.add_column!(:value, header: data[:currency], width: 20, row_style: :currency)
359
+
360
+ sheet.add_column_header_row!
361
+
362
+ row_counter = 0
363
+
364
+ # INCOME section
365
+ if data[:sections][:income]
366
+ add_section_header(sheet, "INCOME", row_counter)
367
+ row_counter += 1
368
+ row_counter = add_single_period_items(sheet, data[:sections][:income][:items], row_counter, 0)
369
+
370
+ total_row = sheet.add_row!(:"total_income_#{row_counter}")
371
+ total_row.add!(:label, value: 'TOTAL INCOME', style: :subtotal)
372
+ total_row.add!(:value, value: data[:sections][:income][:total] || 0, style: :currency_bold)
373
+ row_counter += 1
374
+ end
375
+
376
+ sheet.add_empty_row!
377
+ row_counter += 1
378
+
379
+ # EXPENSES section
380
+ if data[:sections][:expenses]
381
+ add_section_header(sheet, "EXPENSES", row_counter)
382
+ row_counter += 1
383
+ row_counter = add_single_period_items(sheet, data[:sections][:expenses][:items], row_counter, 0)
384
+
385
+ total_row = sheet.add_row!(:"total_expenses_#{row_counter}")
386
+ total_row.add!(:label, value: 'TOTAL EXPENSES', style: :subtotal)
387
+ total_row.add!(:value, value: data[:sections][:expenses][:total] || 0, style: :currency_bold)
388
+ row_counter += 1
389
+ end
390
+
391
+ sheet.add_empty_row!
392
+ row_counter += 1
393
+
394
+ # NET INCOME
395
+ net_income_row = sheet.add_row!(:"net_income_#{row_counter}")
396
+ net_income_row.add!(:label, value: 'NET INCOME', style: :total)
397
+ net_income = (data[:sections][:income][:total] || 0) - (data[:sections][:expenses][:total] || 0)
398
+ net_income_row.add!(:value, value: net_income, style: :currency_total)
399
+
400
+ sheet.position_rows!
401
+ sheet.generate_sheet!
402
+ end
403
+
404
+ def add_single_period_items(sheet, items, row_counter, indent_level)
405
+ return row_counter unless items
406
+
407
+ items.each do |item|
408
+ indent = " " * (indent_level + (item[:indent] || 0))
409
+ row = sheet.add_row!(:"item_#{row_counter}")
410
+ row.add!(:label, value: "#{indent}#{item[:display_name] || humanize_name(item[:name])}")
411
+ row.add!(:value, value: item[:value] || 0, style: :currency)
412
+ row_counter += 1
413
+
414
+ if item[:subcategories]
415
+ row_counter = add_single_period_items(sheet, item[:subcategories], row_counter, indent_level + 1)
416
+ end
417
+ end
418
+
419
+ row_counter
420
+ end
421
+
422
+ # ==========================================
423
+ # Balance Sheet
424
+ # ==========================================
425
+
426
+ def add_balance_sheet(sheet_name, data)
427
+ sheet = @document.add_sheet!(sheet_name)
428
+
429
+ sheet.add_column!(:label, width: 50, row_style: :default_cell)
430
+ sheet.add_column!(:value, header: data[:currency], width: 20, row_style: :currency)
431
+
432
+ sheet.add_column_header_row!
433
+
434
+ row_counter = 0
435
+ @row_refs = {}
436
+
437
+ # ASSETS section
438
+ if data[:sections][:assets]
439
+ add_section_header(sheet, "ASSETS", row_counter)
440
+ row_counter += 1
441
+ row_counter = add_single_period_items(sheet, data[:sections][:assets][:items], row_counter, 0)
442
+
443
+ total_row = sheet.add_row!(:"total_assets_#{row_counter}")
444
+ total_row.add!(:label, value: 'TOTAL ASSETS', style: :total)
445
+ total_row.add!(:value, value: data[:sections][:total_assets][:total] || 0, style: :currency_total)
446
+ @row_refs[:total_assets] = total_row
447
+ row_counter += 1
448
+ end
449
+
450
+ sheet.add_empty_row!
451
+ row_counter += 1
452
+
453
+ # LIABILITIES section
454
+ add_section_header(sheet, "LIABILITIES", row_counter)
455
+ row_counter += 1
456
+ if data[:sections][:liabilities] && data[:sections][:liabilities][:items]
457
+ row_counter = add_single_period_items(sheet, data[:sections][:liabilities][:items], row_counter, 0)
458
+ end
459
+
460
+ total_liabilities_row = sheet.add_row!(:"total_liabilities_#{row_counter}")
461
+ total_liabilities_row.add!(:label, value: 'TOTAL LIABILITIES', style: :subtotal)
462
+ total_liabilities_row.add!(:value, value: data[:sections][:total_liabilities][:total] || 0, style: :currency_bold)
463
+ @row_refs[:total_liabilities] = total_liabilities_row
464
+ row_counter += 1
465
+
466
+ sheet.add_empty_row!
467
+ row_counter += 1
468
+
469
+ # EQUITY section
470
+ add_section_header(sheet, "EQUITY", row_counter)
471
+ row_counter += 1
472
+ if data[:sections][:equity] && data[:sections][:equity][:items]
473
+ row_counter = add_single_period_items(sheet, data[:sections][:equity][:items], row_counter, 0)
474
+ end
475
+
476
+ # Accumulated net income
477
+ if data[:sections][:accumulated_net_income] && data[:sections][:accumulated_net_income][:items]
478
+ row_counter = add_single_period_items(sheet, data[:sections][:accumulated_net_income][:items], row_counter, 0)
479
+ end
480
+
481
+ total_equity_row = sheet.add_row!(:"total_equity_#{row_counter}")
482
+ total_equity_row.add!(:label, value: 'TOTAL EQUITY', style: :subtotal)
483
+ total_equity_row.add!(:value, value: (data[:sections][:equity][:total] || 0) + (data[:sections][:accumulated_net_income][:total] || 0), style: :currency_bold)
484
+ @row_refs[:total_equity] = total_equity_row
485
+ row_counter += 1
486
+
487
+ sheet.add_empty_row!
488
+ row_counter += 1
489
+
490
+ # TOTAL LIABILITIES AND EQUITY with formula
491
+ total_le_row = sheet.add_row!(:"total_le_#{row_counter}")
492
+ total_le_row.add!(:label, value: 'TOTAL LIABILITIES AND EQUITY', style: :total)
493
+ liabilities_ref = @row_refs[:total_liabilities].ref(:value)
494
+ equity_ref = @row_refs[:total_equity].ref(:value)
495
+ total_le_row.add!(:value, value: liabilities_ref + equity_ref, style: :currency_total)
496
+
497
+ sheet.position_rows!
498
+ sheet.generate_sheet!
499
+ end
500
+
501
+ def add_balance_sheet_comparison(sheet_name, periods)
502
+ sheet = @document.add_sheet!(sheet_name)
503
+
504
+ # Define columns
505
+ sheet.add_column!(:label, width: 50, row_style: :default_cell)
506
+ periods.each_with_index do |period_data, idx|
507
+ period = period_data[:period] || period_data[:report][:period]
508
+ header = period[:end].strftime('%b %Y')
509
+ sheet.add_column!(:"period_#{idx}", header: header, width: 15, row_style: :currency)
510
+ end
511
+
512
+ sheet.add_column_header_row!
513
+
514
+ row_counter = 0
515
+ @row_refs = {}
516
+
517
+ # ASSETS section
518
+ add_section_header(sheet, "ASSETS", row_counter)
519
+ row_counter += 1
520
+
521
+ asset_items = collect_items_from_periods(periods, :assets)
522
+ row_counter = add_period_items(sheet, asset_items, periods, row_counter, 0)
523
+
524
+ total_assets_row = sheet.add_row!(:"total_assets_#{row_counter}")
525
+ total_assets_row.add!(:label, value: 'TOTAL ASSETS', style: :total)
526
+ periods.each_with_index do |period_data, idx|
527
+ col = :"period_#{idx}"
528
+ report = period_data[:report] || period_data
529
+ total = report[:sections][:total_assets][:total] rescue 0
530
+ total_assets_row.add!(col, value: total, style: :currency_total)
531
+ end
532
+ @row_refs[:total_assets] = total_assets_row
533
+ row_counter += 1
534
+
535
+ sheet.add_empty_row!
536
+ row_counter += 1
537
+
538
+ # LIABILITIES section
539
+ add_section_header(sheet, "LIABILITIES", row_counter)
540
+ row_counter += 1
541
+
542
+ liability_items = collect_items_from_periods(periods, :liabilities)
543
+ row_counter = add_period_items(sheet, liability_items, periods, row_counter, 0)
544
+
545
+ total_liabilities_row = sheet.add_row!(:"total_liabilities_#{row_counter}")
546
+ total_liabilities_row.add!(:label, value: 'TOTAL LIABILITIES', style: :subtotal)
547
+ periods.each_with_index do |period_data, idx|
548
+ col = :"period_#{idx}"
549
+ report = period_data[:report] || period_data
550
+ total = report[:sections][:total_liabilities][:total] rescue 0
551
+ total_liabilities_row.add!(col, value: total, style: :currency_bold)
552
+ end
553
+ @row_refs[:total_liabilities] = total_liabilities_row
554
+ row_counter += 1
555
+
556
+ sheet.add_empty_row!
557
+ row_counter += 1
558
+
559
+ # EQUITY section
560
+ add_section_header(sheet, "EQUITY", row_counter)
561
+ row_counter += 1
562
+
563
+ equity_items = collect_items_from_periods(periods, :equity)
564
+ row_counter = add_period_items(sheet, equity_items, periods, row_counter, 0)
565
+
566
+ # Accumulated net income
567
+ acc_net_items = collect_items_from_periods(periods, :accumulated_net_income)
568
+ row_counter = add_period_items(sheet, acc_net_items, periods, row_counter, 0)
569
+
570
+ total_equity_row = sheet.add_row!(:"total_equity_#{row_counter}")
571
+ total_equity_row.add!(:label, value: 'TOTAL EQUITY', style: :subtotal)
572
+ periods.each_with_index do |period_data, idx|
573
+ col = :"period_#{idx}"
574
+ report = period_data[:report] || period_data
575
+ equity_total = report[:sections][:equity][:total] rescue 0
576
+ acc_net = report[:sections][:accumulated_net_income][:total] rescue 0
577
+ total_equity_row.add!(col, value: equity_total + acc_net, style: :currency_bold)
578
+ end
579
+ @row_refs[:total_equity] = total_equity_row
580
+ row_counter += 1
581
+
582
+ sheet.add_empty_row!
583
+ row_counter += 1
584
+
585
+ # TOTAL LIABILITIES AND EQUITY with formula
586
+ total_le_row = sheet.add_row!(:"total_le_#{row_counter}")
587
+ total_le_row.add!(:label, value: 'TOTAL LIABILITIES AND EQUITY', style: :total)
588
+ periods.each_with_index do |_, idx|
589
+ col = :"period_#{idx}"
590
+ liab_ref = @row_refs[:total_liabilities].ref(col)
591
+ equity_ref = @row_refs[:total_equity].ref(col)
592
+ total_le_row.add!(col, value: liab_ref + equity_ref, style: :currency_total)
593
+ end
594
+
595
+ sheet.position_rows!
596
+ sheet.generate_sheet!
597
+ end
598
+
599
+ def humanize_name(name)
600
+ name.to_s.split('_').map(&:capitalize).join(' ')
601
+ end
602
+
603
+ # ==========================================
604
+ # Cash Flow Statement
605
+ # ==========================================
606
+
607
+ def add_cash_flow_statement(sheet_name, data)
608
+ sheet = @document.add_sheet!(sheet_name)
609
+
610
+ sheet.add_column!(:label, width: 50, row_style: :default_cell)
611
+ sheet.add_column!(:value, header: data[:currency], width: 20, row_style: :currency)
612
+
613
+ sheet.add_column_header_row!
614
+
615
+ row_counter = 0
616
+ @row_refs = {}
617
+ display_mode = data[:display_mode] || :standard
618
+
619
+ if display_mode == :simple
620
+ row_counter = add_simple_cash_flow(sheet, data, row_counter)
621
+ else
622
+ row_counter = add_standard_cash_flow(sheet, data, row_counter)
623
+ end
624
+
625
+ sheet.position_rows!
626
+ sheet.generate_sheet!
627
+ end
628
+
629
+ def add_standard_cash_flow(sheet, data, row_counter)
630
+ sections = data[:sections] || {}
631
+
632
+ # OPERATING ACTIVITIES
633
+ if sections[:operating]
634
+ add_section_header(sheet, "OPERATING ACTIVITIES", row_counter)
635
+ row_counter += 1
636
+ row_counter = add_single_period_items(sheet, sections[:operating][:items], row_counter, 0)
637
+
638
+ total_row = sheet.add_row!(:"total_operating_#{row_counter}")
639
+ total_row.add!(:label, value: 'Net Cash from Operating Activities', style: :subtotal)
640
+ total_row.add!(:value, value: sections[:operating][:total] || 0, style: :currency_bold)
641
+ @row_refs[:total_operating] = total_row
642
+ row_counter += 1
643
+ end
644
+
645
+ sheet.add_empty_row!
646
+ row_counter += 1
647
+
648
+ # INVESTING ACTIVITIES
649
+ if sections[:investing]
650
+ add_section_header(sheet, "INVESTING ACTIVITIES", row_counter)
651
+ row_counter += 1
652
+ row_counter = add_single_period_items(sheet, sections[:investing][:items], row_counter, 0)
653
+
654
+ total_row = sheet.add_row!(:"total_investing_#{row_counter}")
655
+ total_row.add!(:label, value: 'Net Cash from Investing Activities', style: :subtotal)
656
+ total_row.add!(:value, value: sections[:investing][:total] || 0, style: :currency_bold)
657
+ @row_refs[:total_investing] = total_row
658
+ row_counter += 1
659
+ end
660
+
661
+ sheet.add_empty_row!
662
+ row_counter += 1
663
+
664
+ # FINANCING ACTIVITIES
665
+ if sections[:financing]
666
+ add_section_header(sheet, "FINANCING ACTIVITIES", row_counter)
667
+ row_counter += 1
668
+ row_counter = add_single_period_items(sheet, sections[:financing][:items], row_counter, 0)
669
+
670
+ total_row = sheet.add_row!(:"total_financing_#{row_counter}")
671
+ total_row.add!(:label, value: 'Net Cash from Financing Activities', style: :subtotal)
672
+ total_row.add!(:value, value: sections[:financing][:total] || 0, style: :currency_bold)
673
+ @row_refs[:total_financing] = total_row
674
+ row_counter += 1
675
+ end
676
+
677
+ sheet.add_empty_row!
678
+ row_counter += 1
679
+
680
+ # NET CHANGE IN CASH
681
+ net_change_row = sheet.add_row!(:"net_change_#{row_counter}")
682
+ net_change_row.add!(:label, value: 'NET CHANGE IN CASH', style: :total)
683
+ net_change_row.add!(:value, value: sections[:net_change_in_cash][:total] || 0, style: :currency_total)
684
+ @row_refs[:net_change] = net_change_row
685
+ row_counter += 1
686
+
687
+ sheet.add_empty_row!
688
+ row_counter += 1
689
+
690
+ # BEGINNING CASH BALANCE
691
+ beginning_row = sheet.add_row!(:"beginning_cash_#{row_counter}")
692
+ beginning_row.add!(:label, value: 'Beginning Cash Balance')
693
+ beginning_row.add!(:value, value: sections[:beginning_cash][:total] || 0, style: :currency)
694
+ @row_refs[:beginning_cash] = beginning_row
695
+ row_counter += 1
696
+
697
+ # ENDING CASH BALANCE
698
+ ending_row = sheet.add_row!(:"ending_cash_#{row_counter}")
699
+ ending_row.add!(:label, value: 'ENDING CASH BALANCE', style: :total)
700
+ ending_row.add!(:value, value: sections[:ending_cash][:total] || 0, style: :currency_total)
701
+ row_counter += 1
702
+
703
+ row_counter
704
+ end
705
+
706
+ def add_simple_cash_flow(sheet, data, row_counter)
707
+ sections = data[:sections] || {}
708
+
709
+ # CASH INFLOWS
710
+ if sections[:cash_in]
711
+ add_section_header(sheet, "CASH INFLOWS", row_counter)
712
+ row_counter += 1
713
+ row_counter = add_single_period_items(sheet, sections[:cash_in][:items], row_counter, 0)
714
+
715
+ total_row = sheet.add_row!(:"total_cash_in_#{row_counter}")
716
+ total_row.add!(:label, value: 'TOTAL CASH INFLOWS', style: :subtotal)
717
+ total_row.add!(:value, value: sections[:cash_in][:total] || 0, style: :currency_bold)
718
+ row_counter += 1
719
+ end
720
+
721
+ sheet.add_empty_row!
722
+ row_counter += 1
723
+
724
+ # CASH OUTFLOWS
725
+ if sections[:cash_out]
726
+ add_section_header(sheet, "CASH OUTFLOWS", row_counter)
727
+ row_counter += 1
728
+ row_counter = add_single_period_items(sheet, sections[:cash_out][:items], row_counter, 0)
729
+
730
+ total_row = sheet.add_row!(:"total_cash_out_#{row_counter}")
731
+ total_row.add!(:label, value: 'TOTAL CASH OUTFLOWS', style: :subtotal)
732
+ total_row.add!(:value, value: sections[:cash_out][:total] || 0, style: :currency_bold)
733
+ row_counter += 1
734
+ end
735
+
736
+ sheet.add_empty_row!
737
+ row_counter += 1
738
+
739
+ # NET CHANGE IN CASH
740
+ net_change_row = sheet.add_row!(:"net_change_#{row_counter}")
741
+ net_change_row.add!(:label, value: 'NET CHANGE IN CASH', style: :total)
742
+ net_change_row.add!(:value, value: sections[:net_change_in_cash][:total] || 0, style: :currency_total)
743
+ row_counter += 1
744
+
745
+ sheet.add_empty_row!
746
+ row_counter += 1
747
+
748
+ # BEGINNING AND ENDING CASH
749
+ beginning_row = sheet.add_row!(:"beginning_cash_#{row_counter}")
750
+ beginning_row.add!(:label, value: 'Beginning Cash Balance')
751
+ beginning_row.add!(:value, value: sections[:beginning_cash][:total] || 0, style: :currency)
752
+ row_counter += 1
753
+
754
+ ending_row = sheet.add_row!(:"ending_cash_#{row_counter}")
755
+ ending_row.add!(:label, value: 'ENDING CASH BALANCE', style: :total)
756
+ ending_row.add!(:value, value: sections[:ending_cash][:total] || 0, style: :currency_total)
757
+ row_counter += 1
758
+
759
+ row_counter
760
+ end
761
+
762
+ def add_cash_flow_statement_comparison(sheet_name, periods, report_data)
763
+ sheet = @document.add_sheet!(sheet_name)
764
+ display_mode = report_data[:display_mode] || :standard
765
+
766
+ # Define columns: label + one per period
767
+ sheet.add_column!(:label, width: 50, row_style: :default_cell)
768
+ periods.each_with_index do |period_data, idx|
769
+ period = period_data[:period] || period_data[:report][:period]
770
+ header = period[:start].strftime('%b %Y')
771
+ sheet.add_column!(:"period_#{idx}", header: header, width: 15, row_style: :currency)
772
+ end
773
+
774
+ sheet.add_column_header_row!
775
+
776
+ @row_refs = {}
777
+ row_counter = 0
778
+
779
+ if display_mode == :simple
780
+ row_counter = add_simple_cash_flow_comparison(sheet, periods, row_counter)
781
+ else
782
+ row_counter = add_standard_cash_flow_comparison(sheet, periods, row_counter)
783
+ end
784
+
785
+ sheet.position_rows!
786
+ sheet.generate_sheet!
787
+ end
788
+
789
+ def add_standard_cash_flow_comparison(sheet, periods, row_counter)
790
+ # OPERATING ACTIVITIES
791
+ add_section_header(sheet, "OPERATING ACTIVITIES", row_counter)
792
+ row_counter += 1
793
+
794
+ operating_items = collect_items_from_periods(periods, :operating)
795
+ row_counter = add_period_items(sheet, operating_items, periods, row_counter, 0)
796
+
797
+ total_operating_row = sheet.add_row!(:"total_operating_#{row_counter}")
798
+ total_operating_row.add!(:label, value: 'Net Cash from Operating Activities', style: :subtotal)
799
+ periods.each_with_index do |period_data, idx|
800
+ col = :"period_#{idx}"
801
+ report = period_data[:report] || period_data
802
+ total = report[:sections][:operating][:total] rescue 0
803
+ total_operating_row.add!(col, value: total, style: :currency_bold)
804
+ end
805
+ @row_refs[:total_operating] = total_operating_row
806
+ row_counter += 1
807
+
808
+ sheet.add_empty_row!
809
+ row_counter += 1
810
+
811
+ # INVESTING ACTIVITIES
812
+ add_section_header(sheet, "INVESTING ACTIVITIES", row_counter)
813
+ row_counter += 1
814
+
815
+ investing_items = collect_items_from_periods(periods, :investing)
816
+ row_counter = add_period_items(sheet, investing_items, periods, row_counter, 0)
817
+
818
+ total_investing_row = sheet.add_row!(:"total_investing_#{row_counter}")
819
+ total_investing_row.add!(:label, value: 'Net Cash from Investing Activities', style: :subtotal)
820
+ periods.each_with_index do |period_data, idx|
821
+ col = :"period_#{idx}"
822
+ report = period_data[:report] || period_data
823
+ total = report[:sections][:investing][:total] rescue 0
824
+ total_investing_row.add!(col, value: total, style: :currency_bold)
825
+ end
826
+ @row_refs[:total_investing] = total_investing_row
827
+ row_counter += 1
828
+
829
+ sheet.add_empty_row!
830
+ row_counter += 1
831
+
832
+ # FINANCING ACTIVITIES
833
+ add_section_header(sheet, "FINANCING ACTIVITIES", row_counter)
834
+ row_counter += 1
835
+
836
+ financing_items = collect_items_from_periods(periods, :financing)
837
+ row_counter = add_period_items(sheet, financing_items, periods, row_counter, 0)
838
+
839
+ total_financing_row = sheet.add_row!(:"total_financing_#{row_counter}")
840
+ total_financing_row.add!(:label, value: 'Net Cash from Financing Activities', style: :subtotal)
841
+ periods.each_with_index do |period_data, idx|
842
+ col = :"period_#{idx}"
843
+ report = period_data[:report] || period_data
844
+ total = report[:sections][:financing][:total] rescue 0
845
+ total_financing_row.add!(col, value: total, style: :currency_bold)
846
+ end
847
+ @row_refs[:total_financing] = total_financing_row
848
+ row_counter += 1
849
+
850
+ sheet.add_empty_row!
851
+ row_counter += 1
852
+
853
+ # NET CHANGE IN CASH with formula
854
+ net_change_row = sheet.add_row!(:"net_change_#{row_counter}")
855
+ net_change_row.add!(:label, value: 'NET CHANGE IN CASH', style: :total)
856
+ periods.each_with_index do |_, idx|
857
+ col = :"period_#{idx}"
858
+ operating_ref = @row_refs[:total_operating].ref(col)
859
+ investing_ref = @row_refs[:total_investing].ref(col)
860
+ financing_ref = @row_refs[:total_financing].ref(col)
861
+ net_change_row.add!(col, value: operating_ref + investing_ref + financing_ref, style: :currency_total)
862
+ end
863
+ @row_refs[:net_change] = net_change_row
864
+ row_counter += 1
865
+
866
+ sheet.add_empty_row!
867
+ row_counter += 1
868
+
869
+ # BEGINNING CASH BALANCE
870
+ beginning_row = sheet.add_row!(:"beginning_cash_#{row_counter}")
871
+ beginning_row.add!(:label, value: 'Beginning Cash Balance')
872
+ periods.each_with_index do |period_data, idx|
873
+ col = :"period_#{idx}"
874
+ report = period_data[:report] || period_data
875
+ beginning = report[:sections][:beginning_cash][:total] rescue 0
876
+ beginning_row.add!(col, value: beginning, style: :currency)
877
+ end
878
+ @row_refs[:beginning_cash] = beginning_row
879
+ row_counter += 1
880
+
881
+ # ENDING CASH BALANCE with formula
882
+ ending_row = sheet.add_row!(:"ending_cash_#{row_counter}")
883
+ ending_row.add!(:label, value: 'ENDING CASH BALANCE', style: :total)
884
+ periods.each_with_index do |_, idx|
885
+ col = :"period_#{idx}"
886
+ beginning_ref = @row_refs[:beginning_cash].ref(col)
887
+ net_change_ref = @row_refs[:net_change].ref(col)
888
+ ending_row.add!(col, value: beginning_ref + net_change_ref, style: :currency_total)
889
+ end
890
+ row_counter += 1
891
+
892
+ row_counter
893
+ end
894
+
895
+ def add_simple_cash_flow_comparison(sheet, periods, row_counter)
896
+ # CASH INFLOWS
897
+ add_section_header(sheet, "CASH INFLOWS", row_counter)
898
+ row_counter += 1
899
+
900
+ cash_in_items = collect_items_from_periods(periods, :cash_in)
901
+ row_counter = add_period_items(sheet, cash_in_items, periods, row_counter, 0)
902
+
903
+ total_in_row = sheet.add_row!(:"total_cash_in_#{row_counter}")
904
+ total_in_row.add!(:label, value: 'TOTAL CASH INFLOWS', style: :subtotal)
905
+ periods.each_with_index do |period_data, idx|
906
+ col = :"period_#{idx}"
907
+ report = period_data[:report] || period_data
908
+ total = report[:sections][:cash_in][:total] rescue 0
909
+ total_in_row.add!(col, value: total, style: :currency_bold)
910
+ end
911
+ @row_refs[:total_cash_in] = total_in_row
912
+ row_counter += 1
913
+
914
+ sheet.add_empty_row!
915
+ row_counter += 1
916
+
917
+ # CASH OUTFLOWS
918
+ add_section_header(sheet, "CASH OUTFLOWS", row_counter)
919
+ row_counter += 1
920
+
921
+ cash_out_items = collect_items_from_periods(periods, :cash_out)
922
+ row_counter = add_period_items(sheet, cash_out_items, periods, row_counter, 0)
923
+
924
+ total_out_row = sheet.add_row!(:"total_cash_out_#{row_counter}")
925
+ total_out_row.add!(:label, value: 'TOTAL CASH OUTFLOWS', style: :subtotal)
926
+ periods.each_with_index do |period_data, idx|
927
+ col = :"period_#{idx}"
928
+ report = period_data[:report] || period_data
929
+ total = report[:sections][:cash_out][:total] rescue 0
930
+ total_out_row.add!(col, value: total, style: :currency_bold)
931
+ end
932
+ @row_refs[:total_cash_out] = total_out_row
933
+ row_counter += 1
934
+
935
+ sheet.add_empty_row!
936
+ row_counter += 1
937
+
938
+ # NET CHANGE IN CASH with formula
939
+ net_change_row = sheet.add_row!(:"net_change_#{row_counter}")
940
+ net_change_row.add!(:label, value: 'NET CHANGE IN CASH', style: :total)
941
+ periods.each_with_index do |_, idx|
942
+ col = :"period_#{idx}"
943
+ in_ref = @row_refs[:total_cash_in].ref(col)
944
+ out_ref = @row_refs[:total_cash_out].ref(col)
945
+ net_change_row.add!(col, value: in_ref - out_ref, style: :currency_total)
946
+ end
947
+ @row_refs[:net_change] = net_change_row
948
+ row_counter += 1
949
+
950
+ sheet.add_empty_row!
951
+ row_counter += 1
952
+
953
+ # BEGINNING AND ENDING CASH
954
+ beginning_row = sheet.add_row!(:"beginning_cash_#{row_counter}")
955
+ beginning_row.add!(:label, value: 'Beginning Cash Balance')
956
+ periods.each_with_index do |period_data, idx|
957
+ col = :"period_#{idx}"
958
+ report = period_data[:report] || period_data
959
+ beginning = report[:sections][:beginning_cash][:total] rescue 0
960
+ beginning_row.add!(col, value: beginning, style: :currency)
961
+ end
962
+ @row_refs[:beginning_cash] = beginning_row
963
+ row_counter += 1
964
+
965
+ ending_row = sheet.add_row!(:"ending_cash_#{row_counter}")
966
+ ending_row.add!(:label, value: 'ENDING CASH BALANCE', style: :total)
967
+ periods.each_with_index do |_, idx|
968
+ col = :"period_#{idx}"
969
+ beginning_ref = @row_refs[:beginning_cash].ref(col)
970
+ net_change_ref = @row_refs[:net_change].ref(col)
971
+ ending_row.add!(col, value: beginning_ref + net_change_ref, style: :currency_total)
972
+ end
973
+ row_counter += 1
974
+
975
+ row_counter
976
+ end
977
+
978
+ # ==========================================
979
+ # Scenario Comparison
980
+ # ==========================================
981
+
982
+ def add_scenario_comparison(sheet_name, data)
983
+ sheet = @document.add_sheet!(sheet_name)
984
+
985
+ metadata = data[:metadata]
986
+ base_metrics = data[:base]
987
+ scenarios = data[:scenarios]
988
+ variances = data[:variances]
989
+ scenario_names = metadata[:scenario_names]
990
+
991
+ # Define columns: label + base + one per scenario + variances
992
+ sheet.add_column!(:metric, header: 'Metric', width: 30, row_style: :default_cell)
993
+ sheet.add_column!(:base, header: 'Base', width: 18, row_style: :currency)
994
+
995
+ scenario_names.each_with_index do |name, idx|
996
+ sheet.add_column!(:"scenario_#{idx}", header: humanize_name(name), width: 18, row_style: :currency)
997
+ sheet.add_column!(:"variance_#{idx}", header: "Var %", width: 12, row_style: :percent)
998
+ end
999
+
1000
+ sheet.add_column_header_row!
1001
+
1002
+ # Title row
1003
+ title_row = sheet.add_row!(:title)
1004
+ title_row.add!(:metric, value: "SCENARIO COMPARISON", style: :section_header)
1005
+ sheet.add_empty_row!
1006
+
1007
+ # Period info
1008
+ period_row = sheet.add_row!(:period_info)
1009
+ period_row.add!(:metric, value: "Period: #{metadata[:start_date]} to #{metadata[:end_date]}")
1010
+ sheet.add_empty_row!
1011
+
1012
+ # Use normalized_metrics if available (has labels)
1013
+ normalized_metrics = metadata[:normalized_metrics] || []
1014
+
1015
+ # Add each metric
1016
+ normalized_metrics.each_with_index do |metric_config, metric_idx|
1017
+ metric_key = metric_config[:key]
1018
+ metric_label = metric_config[:label]
1019
+ base_value = base_metrics[metric_key] || 0
1020
+
1021
+ row = sheet.add_row!(:"metric_#{metric_idx}")
1022
+ row.add!(:metric, value: metric_label, style: :default_cell)
1023
+ row.add!(:base, value: base_value, style: :currency_bold)
1024
+
1025
+ scenario_names.each_with_index do |scenario_name, idx|
1026
+ scenario_metrics = scenarios[scenario_name]
1027
+ scenario_value = scenario_metrics ? (scenario_metrics[metric_key] || 0) : 0
1028
+ row.add!(:"scenario_#{idx}", value: scenario_value, style: :currency)
1029
+
1030
+ # Variance percentage
1031
+ if variances[scenario_name]
1032
+ metric_variance = variances[scenario_name][metric_key]
1033
+ if metric_variance
1034
+ pct = metric_variance[:percentage] / 100.0 # Convert to decimal for Excel
1035
+ row.add!(:"variance_#{idx}", value: pct, style: :percent)
1036
+ else
1037
+ row.add!(:"variance_#{idx}", value: 0, style: :percent)
1038
+ end
1039
+ else
1040
+ row.add!(:"variance_#{idx}", value: 0, style: :percent)
1041
+ end
1042
+ end
1043
+ end
1044
+
1045
+ sheet.add_empty_row!
1046
+
1047
+ # Generated timestamp
1048
+ timestamp_row = sheet.add_row!(:timestamp)
1049
+ timestamp_row.add!(:metric, value: "Generated: #{metadata[:generated_at]}")
1050
+
1051
+ sheet.position_rows!
1052
+ sheet.generate_sheet!
1053
+ end
1054
+
1055
+ # ==========================================
1056
+ # Period Comparison
1057
+ # ==========================================
1058
+
1059
+ def add_period_comparison(sheet_name, data)
1060
+ sheet = @document.add_sheet!(sheet_name)
1061
+
1062
+ metadata = data[:metadata]
1063
+ periods = data[:periods]
1064
+ variances = data[:variances]
1065
+ normalized_metrics = metadata[:normalized_metrics] || []
1066
+
1067
+ # Define columns: metric + one per period with variance
1068
+ sheet.add_column!(:metric, header: 'Metric', width: 30, row_style: :default_cell)
1069
+
1070
+ periods.each_with_index do |period, idx|
1071
+ sheet.add_column!(:"period_#{idx}", header: period[:name], width: 18, row_style: :currency)
1072
+ sheet.add_column!(:"variance_#{idx}", header: "Var %", width: 12, row_style: :percent) unless idx == 0
1073
+ end
1074
+
1075
+ sheet.add_column_header_row!
1076
+
1077
+ # Title row
1078
+ title_row = sheet.add_row!(:title)
1079
+ title_row.add!(:metric, value: "PERIOD COMPARISON", style: :section_header)
1080
+ sheet.add_empty_row!
1081
+
1082
+ # Add each metric
1083
+ normalized_metrics.each_with_index do |metric_config, metric_idx|
1084
+ metric_key = metric_config[:variable]
1085
+ metric_label = metric_config[:label]
1086
+
1087
+ row = sheet.add_row!(:"metric_#{metric_idx}")
1088
+ row.add!(:metric, value: metric_label, style: :default_cell)
1089
+
1090
+ periods.each_with_index do |period, idx|
1091
+ period_value = period[:metrics][metric_key] || 0
1092
+ row.add!(:"period_#{idx}", value: period_value, style: :currency_bold)
1093
+
1094
+ # Variance percentage (skip first period)
1095
+ unless idx == 0
1096
+ var_data = variances[idx]
1097
+ if var_data && var_data[:variances]
1098
+ metric_variance = var_data[:variances][metric_key]
1099
+ if metric_variance
1100
+ pct = metric_variance[:percentage] / 100.0
1101
+ row.add!(:"variance_#{idx}", value: pct, style: :percent)
1102
+ else
1103
+ row.add!(:"variance_#{idx}", value: 0, style: :percent)
1104
+ end
1105
+ else
1106
+ row.add!(:"variance_#{idx}", value: 0, style: :percent)
1107
+ end
1108
+ end
1109
+ end
1110
+ end
1111
+
1112
+ sheet.add_empty_row!
1113
+
1114
+ # Generated timestamp
1115
+ timestamp_row = sheet.add_row!(:timestamp)
1116
+ timestamp_row.add!(:metric, value: "Generated: #{metadata[:generated_at]}")
1117
+
1118
+ sheet.position_rows!
1119
+ sheet.generate_sheet!
1120
+ end
1121
+
1122
+ # ==========================================
1123
+ # Custom Sheet
1124
+ # ==========================================
1125
+
1126
+ def add_custom_sheet(sheet_name, data)
1127
+ sheet = @document.add_sheet!(sheet_name)
1128
+
1129
+ # Add custom styles for custom sheet
1130
+ @document.add_style!(:title_large, b: true, sz: 18) rescue nil
1131
+ @document.add_style!(:header_medium, b: true, sz: 14) rescue nil
1132
+ @document.add_style!(:header_small, b: true, sz: 12) rescue nil
1133
+ @document.add_style!(:paragraph_style, wrap_text: true) rescue nil
1134
+ @document.add_style!(:table_header_style, b: true, bg_color: 'E0E0E0') rescue nil
1135
+
1136
+ # Use wider main column
1137
+ sheet.add_column!(:main, width: 80, row_style: :default_cell)
1138
+
1139
+ title = data[:title]
1140
+ sections = data[:sections]
1141
+
1142
+ row_counter = 0
1143
+
1144
+ # Title
1145
+ title_row = sheet.add_row!(:"row_#{row_counter}")
1146
+ title_row.add!(:main, value: title, style: :title_large)
1147
+ row_counter += 1
1148
+ sheet.add_empty_row!
1149
+ row_counter += 1
1150
+
1151
+ # Process sections
1152
+ sections.each do |section|
1153
+ case section[:type]
1154
+ when :header
1155
+ level = section[:level] || 1
1156
+ style = case level
1157
+ when 1 then :title_large
1158
+ when 2 then :header_medium
1159
+ else :header_small
1160
+ end
1161
+ row = sheet.add_row!(:"row_#{row_counter}")
1162
+ row.add!(:main, value: section[:text], style: style)
1163
+ row_counter += 1
1164
+
1165
+ when :paragraph
1166
+ row = sheet.add_row!(:"row_#{row_counter}")
1167
+ row.add!(:main, value: section[:text], style: :paragraph_style)
1168
+ row_counter += 1
1169
+
1170
+ when :spacer
1171
+ (section[:rows] || 1).times do
1172
+ sheet.add_empty_row!
1173
+ row_counter += 1
1174
+ end
1175
+
1176
+ when :key_value
1177
+ row = sheet.add_row!(:"row_#{row_counter}")
1178
+ row.add!(:main, value: "#{section[:label]}: #{section[:value]}")
1179
+ row_counter += 1
1180
+
1181
+ when :bullet_list
1182
+ section[:items].each do |item|
1183
+ row = sheet.add_row!(:"row_#{row_counter}")
1184
+ row.add!(:main, value: " \u2022 #{item}")
1185
+ row_counter += 1
1186
+ end
1187
+
1188
+ when :numbered_list
1189
+ section[:items].each_with_index do |item, idx|
1190
+ row = sheet.add_row!(:"row_#{row_counter}")
1191
+ row.add!(:main, value: " #{idx + 1}. #{item}")
1192
+ row_counter += 1
1193
+ end
1194
+
1195
+ when :table
1196
+ row_counter = add_custom_sheet_inline_table(sheet, section, row_counter)
1197
+
1198
+ when :hr
1199
+ row = sheet.add_row!(:"row_#{row_counter}")
1200
+ row.add!(:main, value: "\u2500" * 50)
1201
+ row_counter += 1
1202
+
1203
+ when :note
1204
+ prefix = case section[:style]
1205
+ when :warning then "[!] "
1206
+ when :error then "[X] "
1207
+ when :success then "[+] "
1208
+ else "[i] "
1209
+ end
1210
+ row = sheet.add_row!(:"row_#{row_counter}")
1211
+ row.add!(:main, value: "#{prefix}#{section[:text]}")
1212
+ row_counter += 1
1213
+ end
1214
+ end
1215
+
1216
+ # Generated timestamp if present
1217
+ if data[:generated_at]
1218
+ sheet.add_empty_row!
1219
+ row_counter += 1
1220
+ timestamp_row = sheet.add_row!(:"row_#{row_counter}")
1221
+ timestamp_row.add!(:main, value: "Generated: #{data[:generated_at]}")
1222
+ end
1223
+
1224
+ sheet.position_rows!
1225
+ sheet.generate_sheet!
1226
+ end
1227
+
1228
+ def add_custom_sheet_inline_table(sheet, section, start_row)
1229
+ headers = section[:headers]
1230
+ rows = section[:rows]
1231
+ row_counter = start_row
1232
+
1233
+ # Add title if present
1234
+ if section[:title]
1235
+ title_row = sheet.add_row!(:"row_#{row_counter}")
1236
+ title_row.add!(:main, value: section[:title], style: :header_small)
1237
+ row_counter += 1
1238
+ end
1239
+
1240
+ # For inline tables, we format as text in the main column
1241
+ # Build formatted header row
1242
+ header_text = headers.join(" | ")
1243
+ header_row = sheet.add_row!(:"row_#{row_counter}")
1244
+ header_row.add!(:main, value: header_text, style: :table_header_style)
1245
+ row_counter += 1
1246
+
1247
+ # Separator
1248
+ sep_row = sheet.add_row!(:"row_#{row_counter}")
1249
+ sep_row.add!(:main, value: "-" * header_text.length)
1250
+ row_counter += 1
1251
+
1252
+ # Data rows
1253
+ rows.each do |row_data|
1254
+ data_text = row_data.join(" | ")
1255
+ data_row = sheet.add_row!(:"row_#{row_counter}")
1256
+ data_row.add!(:main, value: data_text)
1257
+ row_counter += 1
1258
+ end
1259
+
1260
+ row_counter
1261
+ end
1262
+ end
1263
+ end
1264
+ end