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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +24 -0
- data/CHANGELOG.md +9 -0
- data/CONTRIBUTING.md +20 -0
- data/LICENSE +21 -0
- data/QUICKSTART.md +56 -0
- data/README.md +74 -0
- data/Rakefile +23 -0
- data/SECURITY.md +14 -0
- data/assets/fin_it_logo.png +0 -0
- data/lib/fin_it/account.rb +120 -0
- data/lib/fin_it/calculator/currency_conversion.rb +27 -0
- data/lib/fin_it/calculator/date_helpers.rb +53 -0
- data/lib/fin_it/calculator/variable_hashing.rb +120 -0
- data/lib/fin_it/calculator.rb +480 -0
- data/lib/fin_it/categories/category.rb +137 -0
- data/lib/fin_it/complex_model.rb +169 -0
- data/lib/fin_it/dsl/account_builder.rb +35 -0
- data/lib/fin_it/dsl/calculated_builder.rb +87 -0
- data/lib/fin_it/dsl/config_builder.rb +58 -0
- data/lib/fin_it/dsl/model_builder.rb +938 -0
- data/lib/fin_it/dsl/model_template_builder.rb +29 -0
- data/lib/fin_it/dsl/plan_builder.rb +52 -0
- data/lib/fin_it/dsl/project_inheritance_resolver.rb +46 -0
- data/lib/fin_it/dsl/variable_builder.rb +41 -0
- data/lib/fin_it/dsl.rb +13 -0
- data/lib/fin_it/engine.rb +15 -0
- data/lib/fin_it/financial_model/account_balances.rb +99 -0
- data/lib/fin_it/financial_model/account_hierarchy.rb +158 -0
- data/lib/fin_it/financial_model/category_values.rb +179 -0
- data/lib/fin_it/financial_model/currency_helpers.rb +14 -0
- data/lib/fin_it/financial_model/date_helpers.rb +58 -0
- data/lib/fin_it/financial_model/debugging.rb +353 -0
- data/lib/fin_it/financial_model/period_flows.rb +121 -0
- data/lib/fin_it/financial_model/validation.rb +85 -0
- data/lib/fin_it/financial_model/variable_matching.rb +49 -0
- data/lib/fin_it/financial_model.rb +395 -0
- data/lib/fin_it/model_template.rb +121 -0
- data/lib/fin_it/outputs/base_output.rb +51 -0
- data/lib/fin_it/outputs/console_output.rb +1528 -0
- data/lib/fin_it/outputs/monthly_console_output.rb +145 -0
- data/lib/fin_it/outputs/zaxcel_output.rb +1264 -0
- data/lib/fin_it/payment_schedule.rb +112 -0
- data/lib/fin_it/plan.rb +159 -0
- data/lib/fin_it/reports/balance_sheet.rb +638 -0
- data/lib/fin_it/reports/base_report.rb +239 -0
- data/lib/fin_it/reports/cash_flow_statement.rb +480 -0
- data/lib/fin_it/reports/custom_sheet.rb +436 -0
- data/lib/fin_it/reports/income_statement.rb +793 -0
- data/lib/fin_it/reports/period_comparison.rb +309 -0
- data/lib/fin_it/reports/scenario_comparison.rb +296 -0
- data/lib/fin_it/temporal_value.rb +349 -0
- data/lib/fin_it/transaction_generator/account_resolver.rb +118 -0
- data/lib/fin_it/transaction_generator/cache_management.rb +39 -0
- data/lib/fin_it/transaction_generator/date_generation.rb +57 -0
- data/lib/fin_it/transaction_generator.rb +357 -0
- data/lib/fin_it/version.rb +6 -0
- data/lib/fin_it.rb +27 -0
- data/test/fin_it/calculator_test.rb +109 -0
- data/test/fin_it/complex_model_test.rb +198 -0
- data/test/fin_it/debugging_test.rb +112 -0
- data/test/fin_it/driver_variables_test.rb +109 -0
- data/test/fin_it/dsl_test.rb +581 -0
- data/test/fin_it/financial_model_test.rb +196 -0
- data/test/fin_it/frequency_test.rb +51 -0
- data/test/fin_it/outputs/console_output_test.rb +249 -0
- data/test/fin_it/plan_test.rb +281 -0
- data/test/fin_it/reports/account_balance_test.rb +232 -0
- data/test/fin_it/reports/balance_sheet_test.rb +355 -0
- data/test/fin_it/reports/cash_flow_statement_test.rb +234 -0
- data/test/fin_it/reports/custom_sheet_test.rb +246 -0
- data/test/fin_it/reports/income_statement_test.rb +431 -0
- data/test/fin_it/reports/period_comparison_test.rb +226 -0
- data/test/fin_it/reports/restaurant_model_test.rb +225 -0
- data/test/fin_it/reports/scenario_comparison_test.rb +414 -0
- data/test/scripts/generate_demo_reports.rb +47 -0
- data/test/scripts/startup_saas_demo.rb +62 -0
- data/test/test_helper.rb +25 -0
- data/test/verify_accounting_equation.rb +91 -0
- metadata +264 -0
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module FinIt
|
|
6
|
+
module Outputs
|
|
7
|
+
class ConsoleOutput < BaseOutput
|
|
8
|
+
def generate
|
|
9
|
+
data = @report.generate
|
|
10
|
+
currency = data[:currency] || (data[:metadata] ? data[:metadata][:currency] : nil)
|
|
11
|
+
report_type = data[:report_type] || (data[:metadata] ? data[:metadata][:report_type] : nil)
|
|
12
|
+
|
|
13
|
+
# Handle special report types early since they have different structures
|
|
14
|
+
case report_type
|
|
15
|
+
when "ScenarioComparison"
|
|
16
|
+
output_scenario_comparison(data, currency)
|
|
17
|
+
return nil
|
|
18
|
+
when "PeriodComparison"
|
|
19
|
+
output_period_comparison(data, currency)
|
|
20
|
+
return nil
|
|
21
|
+
when "CustomSheet"
|
|
22
|
+
output_custom_sheet(data)
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check if we have multiple periods to display as columns
|
|
27
|
+
periods_data = options[:periods]
|
|
28
|
+
if periods_data
|
|
29
|
+
# Handle hash from period_summary
|
|
30
|
+
if periods_data.is_a?(Hash) && periods_data[:periods]
|
|
31
|
+
periods_array = periods_data[:periods]
|
|
32
|
+
elsif periods_data.is_a?(Array)
|
|
33
|
+
periods_array = periods_data
|
|
34
|
+
else
|
|
35
|
+
periods_array = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if periods_array && periods_array.length > 1
|
|
39
|
+
output_periods_as_columns(periods_data, report_type, currency)
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts "=" * 80
|
|
45
|
+
project_name = data[:project] ? " - #{data[:project].to_s.split('_').map(&:capitalize).join(' ')}" : ""
|
|
46
|
+
puts "#{report_type.upcase}#{project_name} (#{currency})"
|
|
47
|
+
puts "Period: #{data[:period][:start]} to #{data[:period][:end]}"
|
|
48
|
+
puts "=" * 80
|
|
49
|
+
|
|
50
|
+
# Handle different report types
|
|
51
|
+
case report_type
|
|
52
|
+
when "BalanceSheet"
|
|
53
|
+
output_balance_sheet(data, currency)
|
|
54
|
+
when "CashFlowStatement"
|
|
55
|
+
output_cash_flow_statement(data, currency)
|
|
56
|
+
else
|
|
57
|
+
output_income_statement(data, currency)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "=" * 80
|
|
61
|
+
|
|
62
|
+
# Add statement comments if present
|
|
63
|
+
if data[:statement_comments] && data[:statement_comments].any?
|
|
64
|
+
output_statement_comments(data[:statement_comments])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add monthly breakdown if requested (only for income statements)
|
|
68
|
+
if options[:include_monthly] && report_type != "BalanceSheet"
|
|
69
|
+
puts "\n"
|
|
70
|
+
output_monthly_breakdown(data, currency)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Return the string output if needed
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Output multiple periods as columns (e.g., from generate_monthly or period_summary)
|
|
78
|
+
def output_periods_as_columns(periods_data, report_type, currency)
|
|
79
|
+
# periods_data can be:
|
|
80
|
+
# 1. Array of { period: {...}, report: {...} } from generate_monthly
|
|
81
|
+
# 2. Hash with :periods array from period_summary
|
|
82
|
+
|
|
83
|
+
if periods_data.is_a?(Hash) && periods_data[:periods]
|
|
84
|
+
# From period_summary
|
|
85
|
+
periods = periods_data[:periods]
|
|
86
|
+
frequency = periods_data[:frequency] || :monthly
|
|
87
|
+
else
|
|
88
|
+
# From generate_monthly or similar
|
|
89
|
+
periods = periods_data
|
|
90
|
+
frequency = :monthly
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return if periods.empty?
|
|
94
|
+
|
|
95
|
+
# Format period labels
|
|
96
|
+
period_labels = periods.map do |p|
|
|
97
|
+
period = p[:period] || p
|
|
98
|
+
start_date = period[:start] || period
|
|
99
|
+
format_period_label(start_date, frequency)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Determine column width
|
|
103
|
+
col_width = [15, 100 / (period_labels.length + 1)].max
|
|
104
|
+
total_width = 40 + (col_width * period_labels.length)
|
|
105
|
+
header_format = "%-40s" + (" %#{col_width-1}s" * period_labels.length)
|
|
106
|
+
|
|
107
|
+
puts "=" * total_width
|
|
108
|
+
puts "#{report_type.upcase} - PERIOD COMPARISON (#{currency})"
|
|
109
|
+
puts "=" * total_width
|
|
110
|
+
puts header_format % (["Item"] + period_labels)
|
|
111
|
+
puts "-" * total_width
|
|
112
|
+
|
|
113
|
+
case report_type
|
|
114
|
+
when "BalanceSheet"
|
|
115
|
+
output_balance_sheet_periods_columns(periods, header_format, currency, col_width)
|
|
116
|
+
when "CashFlowStatement"
|
|
117
|
+
output_cash_flow_statement_periods_columns(periods, header_format, currency, col_width)
|
|
118
|
+
else
|
|
119
|
+
output_income_statement_periods_columns(periods, header_format, currency, col_width)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
puts "=" * total_width
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_period_label(date, frequency)
|
|
126
|
+
date = date[:start] if date.is_a?(Hash) && date[:start]
|
|
127
|
+
case frequency
|
|
128
|
+
when :monthly
|
|
129
|
+
date.strftime("%b %Y")
|
|
130
|
+
when :quarterly
|
|
131
|
+
quarter = ((date.month - 1) / 3) + 1
|
|
132
|
+
"Q#{quarter} #{date.year}"
|
|
133
|
+
when :annual
|
|
134
|
+
date.year.to_s
|
|
135
|
+
when :weekly
|
|
136
|
+
date.strftime("%b %d, %Y")
|
|
137
|
+
when :daily
|
|
138
|
+
date.strftime("%b %d, %Y")
|
|
139
|
+
else
|
|
140
|
+
date.strftime("%b %Y")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def output_income_statement(data, currency)
|
|
147
|
+
display_items = data[:display_items] || []
|
|
148
|
+
|
|
149
|
+
# Output display items before income section
|
|
150
|
+
output_display_items(display_items, :before, :income, currency)
|
|
151
|
+
|
|
152
|
+
# Income section
|
|
153
|
+
if data[:sections][:income]
|
|
154
|
+
output_section(data[:sections][:income], currency, "INCOME", show_total: true)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Output display items after income section
|
|
158
|
+
output_display_items(display_items, :after, :income, currency)
|
|
159
|
+
|
|
160
|
+
# COGS section (if separate_cogs mode)
|
|
161
|
+
if data[:sections][:cogs]
|
|
162
|
+
output_display_items(display_items, :before, :cogs, currency)
|
|
163
|
+
output_section(data[:sections][:cogs], currency, "COST OF GOODS SOLD", show_total: true)
|
|
164
|
+
output_display_items(display_items, :after, :cogs, currency)
|
|
165
|
+
|
|
166
|
+
# Gross Margin
|
|
167
|
+
if data[:sections][:gross_margin]
|
|
168
|
+
puts "\n"
|
|
169
|
+
puts "-" * 80
|
|
170
|
+
gross_margin = data[:sections][:gross_margin][:total] || data[:sections][:gross_margin][:value] || 0
|
|
171
|
+
puts "%-60s %20s" % ["GROSS MARGIN", format_currency(gross_margin, currency)]
|
|
172
|
+
|
|
173
|
+
# Output display items after gross margin
|
|
174
|
+
output_display_items(display_items, :after, :gross_margin, currency)
|
|
175
|
+
# Output display items in column next to gross margin
|
|
176
|
+
output_display_items_column(display_items, :gross_margin, currency)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Expense section (operating expenses)
|
|
181
|
+
if data[:sections][:expenses]
|
|
182
|
+
output_display_items(display_items, :before, :expenses, currency)
|
|
183
|
+
section_label = data[:expense_display_mode] == :separate_cogs ? "OPERATING EXPENSES" : "EXPENSES"
|
|
184
|
+
output_section(data[:sections][:expenses], currency, section_label, show_total: true)
|
|
185
|
+
output_display_items(display_items, :after, :expenses, currency)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Net Income
|
|
189
|
+
if data[:sections][:net_income]
|
|
190
|
+
output_display_items(display_items, :before, :net_income, currency)
|
|
191
|
+
puts "\n"
|
|
192
|
+
puts "-" * 80
|
|
193
|
+
net_income = data[:sections][:net_income][:total] || data[:sections][:net_income][:value] || 0
|
|
194
|
+
puts "%-60s %20s" % ["NET INCOME", format_currency(net_income, currency)]
|
|
195
|
+
output_display_items(display_items, :after, :net_income, currency)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def output_display_items(display_items, position_type, section_key, currency)
|
|
200
|
+
items = display_items.select { |item| item[position_type] == section_key }
|
|
201
|
+
return if items.empty?
|
|
202
|
+
|
|
203
|
+
# Sort by order
|
|
204
|
+
items = items.sort_by { |item| item[:order] || 0 }
|
|
205
|
+
|
|
206
|
+
items.each do |item|
|
|
207
|
+
value_str = format_display_value(item[:value], item[:format], currency)
|
|
208
|
+
puts "%-60s %20s" % [item[:label], value_str]
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def output_display_items_column(display_items, section_key, currency)
|
|
213
|
+
items = display_items.select { |item| item[:column] == section_key }
|
|
214
|
+
return if items.empty?
|
|
215
|
+
|
|
216
|
+
# For single period, column items are displayed on the same line or next line
|
|
217
|
+
# This is a simplified version - full column support would require two-pass rendering
|
|
218
|
+
items.each do |item|
|
|
219
|
+
value_str = format_display_value(item[:value], item[:format], currency)
|
|
220
|
+
puts "%-60s %20s" % [" #{item[:label]}", value_str]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def output_balance_sheet(data, currency)
|
|
225
|
+
# Assets section
|
|
226
|
+
if data[:sections][:assets]
|
|
227
|
+
output_balance_sheet_section(data[:sections][:assets], currency, "ASSETS")
|
|
228
|
+
|
|
229
|
+
# Show asset subtotals
|
|
230
|
+
if data[:sections][:assets][:current_assets] || data[:sections][:assets][:fixed_assets]
|
|
231
|
+
puts "\n"
|
|
232
|
+
puts "-" * 80
|
|
233
|
+
if data[:sections][:assets][:current_assets]
|
|
234
|
+
puts "%-60s %20s" % ["Current Assets", format_currency(data[:sections][:assets][:current_assets], currency)]
|
|
235
|
+
end
|
|
236
|
+
if data[:sections][:assets][:fixed_assets]
|
|
237
|
+
puts "%-60s %20s" % ["Fixed Assets", format_currency(data[:sections][:assets][:fixed_assets], currency)]
|
|
238
|
+
end
|
|
239
|
+
puts "%-60s %20s" % ["TOTAL ASSETS", format_currency(data[:sections][:assets][:total], currency)]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Liabilities section
|
|
244
|
+
if data[:sections][:liabilities]
|
|
245
|
+
output_balance_sheet_section(data[:sections][:liabilities], currency, "LIABILITIES")
|
|
246
|
+
|
|
247
|
+
# Show liability subtotals
|
|
248
|
+
if data[:sections][:liabilities][:current_liabilities] || data[:sections][:liabilities][:long_term_liabilities]
|
|
249
|
+
puts "\n"
|
|
250
|
+
puts "-" * 80
|
|
251
|
+
if data[:sections][:liabilities][:current_liabilities]
|
|
252
|
+
current_liab = data[:sections][:liabilities][:current_liabilities]
|
|
253
|
+
puts "%-60s %20s" % ["Current Liabilities", format_currency(current_liab, currency)]
|
|
254
|
+
end
|
|
255
|
+
if data[:sections][:liabilities][:long_term_liabilities]
|
|
256
|
+
long_term_liab = data[:sections][:liabilities][:long_term_liabilities]
|
|
257
|
+
puts "%-60s %20s" % ["Long-term Liabilities", format_currency(long_term_liab, currency)]
|
|
258
|
+
end
|
|
259
|
+
total_liab = data[:sections][:liabilities][:total]
|
|
260
|
+
puts "%-60s %20s" % ["TOTAL LIABILITIES", format_currency(total_liab, currency)]
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Equity section
|
|
265
|
+
if data[:sections][:equity]
|
|
266
|
+
output_section(data[:sections][:equity], currency, "EQUITY")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Accumulated Net Income section (separate from equity)
|
|
270
|
+
if data[:sections][:accumulated_net_income]
|
|
271
|
+
output_section(data[:sections][:accumulated_net_income], currency, "ACCUMULATED NET INCOME")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Total Liabilities and Equity
|
|
275
|
+
if data[:sections][:total_liabilities_and_equity]
|
|
276
|
+
puts "\n"
|
|
277
|
+
puts "-" * 80
|
|
278
|
+
total = data[:sections][:total_liabilities_and_equity][:total] || data[:sections][:total_liabilities_and_equity][:value] || 0
|
|
279
|
+
puts "%-60s %20s" % ["TOTAL LIABILITIES AND EQUITY", format_currency(total, currency)]
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def output_cash_flow_statement(data, currency)
|
|
284
|
+
display_mode = data[:display_mode] || :standard
|
|
285
|
+
|
|
286
|
+
if display_mode == :simple
|
|
287
|
+
output_simple_cash_flow(data, currency)
|
|
288
|
+
else
|
|
289
|
+
output_standard_cash_flow(data, currency)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def output_standard_cash_flow(data, currency)
|
|
294
|
+
sections = data[:sections] || {}
|
|
295
|
+
|
|
296
|
+
# Operating Activities
|
|
297
|
+
if sections[:operating]
|
|
298
|
+
puts "\nOPERATING ACTIVITIES"
|
|
299
|
+
puts "-" * 80
|
|
300
|
+
if sections[:operating][:items]
|
|
301
|
+
sections[:operating][:items].each do |item|
|
|
302
|
+
output_cash_flow_item(item, currency)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
puts "-" * 80
|
|
306
|
+
puts "%-60s %20s" % ["Net Cash from Operating Activities", format_currency(sections[:operating][:total] || 0, currency)]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Investing Activities
|
|
310
|
+
if sections[:investing]
|
|
311
|
+
puts "\nINVESTING ACTIVITIES"
|
|
312
|
+
puts "-" * 80
|
|
313
|
+
if sections[:investing][:items] && sections[:investing][:items].any?
|
|
314
|
+
sections[:investing][:items].each do |item|
|
|
315
|
+
output_cash_flow_item(item, currency)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
puts "-" * 80
|
|
319
|
+
puts "%-60s %20s" % ["Net Cash from Investing Activities", format_currency(sections[:investing][:total] || 0, currency)]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Financing Activities
|
|
323
|
+
if sections[:financing]
|
|
324
|
+
puts "\nFINANCING ACTIVITIES"
|
|
325
|
+
puts "-" * 80
|
|
326
|
+
if sections[:financing][:items] && sections[:financing][:items].any?
|
|
327
|
+
sections[:financing][:items].each do |item|
|
|
328
|
+
output_cash_flow_item(item, currency)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
puts "-" * 80
|
|
332
|
+
puts "%-60s %20s" % ["Net Cash from Financing Activities", format_currency(sections[:financing][:total] || 0, currency)]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Summary
|
|
336
|
+
puts "\n"
|
|
337
|
+
puts "=" * 80
|
|
338
|
+
|
|
339
|
+
if sections[:net_change_in_cash]
|
|
340
|
+
net_change = sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0
|
|
341
|
+
puts "%-60s %20s" % ["NET CHANGE IN CASH", format_currency(net_change, currency)]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
if sections[:beginning_cash]
|
|
345
|
+
beginning_cash = sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0
|
|
346
|
+
puts "%-60s %20s" % ["Beginning Cash Balance", format_currency(beginning_cash, currency)]
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
if sections[:ending_cash]
|
|
350
|
+
ending_cash = sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0
|
|
351
|
+
puts "%-60s %20s" % ["ENDING CASH BALANCE", format_currency(ending_cash, currency)]
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def output_simple_cash_flow(data, currency)
|
|
356
|
+
sections = data[:sections] || {}
|
|
357
|
+
|
|
358
|
+
# Cash Inflows
|
|
359
|
+
if sections[:cash_in]
|
|
360
|
+
puts "\nCASH INFLOWS"
|
|
361
|
+
puts "-" * 80
|
|
362
|
+
if sections[:cash_in][:items]
|
|
363
|
+
sections[:cash_in][:items].each do |item|
|
|
364
|
+
output_cash_flow_item(item, currency)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
puts "-" * 80
|
|
368
|
+
puts "%-60s %20s" % ["Total Cash Inflows", format_currency(sections[:cash_in][:total] || 0, currency)]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Cash Outflows
|
|
372
|
+
if sections[:cash_out]
|
|
373
|
+
puts "\nCASH OUTFLOWS"
|
|
374
|
+
puts "-" * 80
|
|
375
|
+
if sections[:cash_out][:items]
|
|
376
|
+
sections[:cash_out][:items].each do |item|
|
|
377
|
+
output_cash_flow_item(item, currency)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
puts "-" * 80
|
|
381
|
+
puts "%-60s %20s" % ["Total Cash Outflows", format_currency(sections[:cash_out][:total] || 0, currency)]
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Summary
|
|
385
|
+
puts "\n"
|
|
386
|
+
puts "=" * 80
|
|
387
|
+
|
|
388
|
+
if sections[:net_change_in_cash]
|
|
389
|
+
net_change = sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0
|
|
390
|
+
puts "%-60s %20s" % ["NET CHANGE IN CASH", format_currency(net_change, currency)]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
if sections[:beginning_cash]
|
|
394
|
+
beginning_cash = sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0
|
|
395
|
+
puts "%-60s %20s" % ["Beginning Cash Balance", format_currency(beginning_cash, currency)]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
if sections[:ending_cash]
|
|
399
|
+
ending_cash = sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0
|
|
400
|
+
puts "%-60s %20s" % ["ENDING CASH BALANCE", format_currency(ending_cash, currency)]
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def output_cash_flow_item(item, currency, indent = 0)
|
|
405
|
+
prefix = " " * indent
|
|
406
|
+
display_name = item[:display_name] || item[:name]
|
|
407
|
+
value = item[:value] || 0
|
|
408
|
+
puts "%-60s %20s" % ["#{prefix}#{display_name}", format_currency(value, currency)]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def output_balance_sheet_section(section, currency, section_name)
|
|
412
|
+
puts "\n#{section_name}"
|
|
413
|
+
puts "-" * 80
|
|
414
|
+
|
|
415
|
+
if section[:items]
|
|
416
|
+
section[:items].each do |item|
|
|
417
|
+
output_item_row(item, currency, 0, show_category_total: false)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def output_section(section, currency, section_name, show_total: false)
|
|
423
|
+
puts "\n#{section_name}"
|
|
424
|
+
puts "-" * 80
|
|
425
|
+
|
|
426
|
+
if section[:items]
|
|
427
|
+
section[:items].each do |item|
|
|
428
|
+
output_item_row(item, currency, show_category_total: false)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Show section total only if requested (for top-level sections)
|
|
433
|
+
if show_total && section[:total]
|
|
434
|
+
puts "-" * 80
|
|
435
|
+
puts "%-60s %20s" % ["TOTAL #{section_name}", format_currency(section[:total], currency)]
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def output_item_row(item, currency, indent = 0, show_category_total: false)
|
|
440
|
+
prefix = " " * indent
|
|
441
|
+
display_name = item[:display_name] || item[:name]
|
|
442
|
+
|
|
443
|
+
if item[:subcategories] && item[:subcategories].any?
|
|
444
|
+
# Parent category with children - show header only if no value at parent level
|
|
445
|
+
if item[:value] && item[:value] != 0
|
|
446
|
+
# Show parent with value, then children
|
|
447
|
+
puts "%-60s %20s" % ["#{prefix}#{display_name}", format_currency(item[:value], currency)]
|
|
448
|
+
item[:subcategories].each do |sub|
|
|
449
|
+
output_item_row(sub, currency, indent + 1, show_category_total: show_category_total)
|
|
450
|
+
end
|
|
451
|
+
else
|
|
452
|
+
# Show parent as header, then children
|
|
453
|
+
puts "#{prefix}#{display_name}:"
|
|
454
|
+
item[:subcategories].each do |sub|
|
|
455
|
+
output_item_row(sub, currency, indent + 1, show_category_total: show_category_total)
|
|
456
|
+
end
|
|
457
|
+
# Show subtotal only if explicitly requested (for nested categories)
|
|
458
|
+
if show_category_total && item[:value]
|
|
459
|
+
puts "%-60s %20s" % ["#{prefix} Subtotal", format_currency(item[:value], currency)]
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
else
|
|
463
|
+
# Leaf item
|
|
464
|
+
value_str = format_currency(item[:value], currency)
|
|
465
|
+
puts "%-60s %20s" % ["#{prefix}#{display_name}", value_str]
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def output_item(item, currency, indent = 0)
|
|
470
|
+
# Legacy method for backward compatibility
|
|
471
|
+
output_item_row(item, currency, indent)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def format_currency(value, currency = nil)
|
|
475
|
+
currency ||= @report.output_currency
|
|
476
|
+
|
|
477
|
+
if value.is_a?(Money)
|
|
478
|
+
"#{currency_symbol(value.currency.iso_code)}#{format_number(value)}"
|
|
479
|
+
elsif value
|
|
480
|
+
"#{currency_symbol(currency)}#{format_number(value)}"
|
|
481
|
+
else
|
|
482
|
+
"#{currency_symbol(currency)}0"
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def format_display_value(value, format_type, currency = nil)
|
|
487
|
+
currency ||= @report.output_currency
|
|
488
|
+
|
|
489
|
+
case format_type
|
|
490
|
+
when :percentage
|
|
491
|
+
"#{format_number(value)}%"
|
|
492
|
+
when :currency
|
|
493
|
+
format_currency(value, currency)
|
|
494
|
+
when :number
|
|
495
|
+
format_number(value)
|
|
496
|
+
else
|
|
497
|
+
format_number(value)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def format_number(value)
|
|
502
|
+
if value.is_a?(Money)
|
|
503
|
+
formatted = sprintf("%.2f", value.to_f)
|
|
504
|
+
elsif value.is_a?(Numeric)
|
|
505
|
+
# Always use 2 decimal places
|
|
506
|
+
formatted = sprintf("%.2f", value)
|
|
507
|
+
else
|
|
508
|
+
formatted = value.to_s
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Add thousands separator (commas)
|
|
512
|
+
if formatted.is_a?(String) && formatted =~ /^-?\d+\.\d+$/
|
|
513
|
+
# Split on decimal point
|
|
514
|
+
parts = formatted.split('.')
|
|
515
|
+
integer_part = parts[0]
|
|
516
|
+
decimal_part = parts[1]
|
|
517
|
+
|
|
518
|
+
# Handle negative sign
|
|
519
|
+
is_negative = integer_part.start_with?('-')
|
|
520
|
+
integer_part = integer_part.sub(/^-/, '') if is_negative
|
|
521
|
+
|
|
522
|
+
# Add commas to integer part
|
|
523
|
+
integer_part = integer_part.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
524
|
+
|
|
525
|
+
# Restore negative sign if needed
|
|
526
|
+
integer_part = "-#{integer_part}" if is_negative
|
|
527
|
+
|
|
528
|
+
formatted = "#{integer_part}.#{decimal_part}"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
formatted
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def currency_symbol(currency_code)
|
|
535
|
+
case currency_code.to_s.upcase
|
|
536
|
+
when 'USD'
|
|
537
|
+
'$'
|
|
538
|
+
when 'EUR'
|
|
539
|
+
'€'
|
|
540
|
+
when 'GBP'
|
|
541
|
+
'£'
|
|
542
|
+
when 'JPY'
|
|
543
|
+
'¥'
|
|
544
|
+
else
|
|
545
|
+
"#{currency_code} "
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def output_statement_comments(comments)
|
|
550
|
+
puts "\n" + "=" * 80
|
|
551
|
+
puts "STATEMENT NOTES"
|
|
552
|
+
puts "=" * 80
|
|
553
|
+
|
|
554
|
+
comments.each do |category_name, comment|
|
|
555
|
+
puts "• #{humanize_category_name(category_name)}: #{comment}"
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
puts "=" * 80
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def humanize_category_name(name)
|
|
562
|
+
name.to_s.split('_').map(&:capitalize).join(' ')
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def output_monthly_breakdown(data, currency)
|
|
566
|
+
breakdown_mode = options[:monthly_breakdown] || :detailed
|
|
567
|
+
|
|
568
|
+
puts "=" * 120
|
|
569
|
+
puts "MONTHLY BREAKDOWN (#{breakdown_mode.to_s.upcase})"
|
|
570
|
+
puts "=" * 120
|
|
571
|
+
|
|
572
|
+
if breakdown_mode == :average
|
|
573
|
+
output_average_monthly(data, currency)
|
|
574
|
+
else
|
|
575
|
+
output_detailed_monthly(data, currency)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def output_average_monthly(data, currency)
|
|
580
|
+
start_date = @report.start_date
|
|
581
|
+
end_date = @report.end_date
|
|
582
|
+
|
|
583
|
+
# Calculate number of months
|
|
584
|
+
months_count = ((end_date.year - start_date.year) * 12 + end_date.month - start_date.month + 1)
|
|
585
|
+
|
|
586
|
+
puts "Average Month (calculated over #{months_count} months: #{start_date.strftime('%b %Y')} - #{end_date.strftime('%b %Y')})"
|
|
587
|
+
puts "=" * 120
|
|
588
|
+
puts ""
|
|
589
|
+
|
|
590
|
+
# Calculate averages
|
|
591
|
+
puts "%-40s %20s" % ["Item", "Avg Month (#{currency})"]
|
|
592
|
+
puts "-" * 120
|
|
593
|
+
|
|
594
|
+
# Income section
|
|
595
|
+
if data[:sections][:income]
|
|
596
|
+
output_monthly_section(data[:sections][:income], currency, months_count, "INCOME")
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
puts ""
|
|
600
|
+
|
|
601
|
+
# Expense section
|
|
602
|
+
if data[:sections][:expenses]
|
|
603
|
+
output_monthly_section(data[:sections][:expenses], currency, months_count, "EXPENSES")
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
puts ""
|
|
607
|
+
puts "-" * 120
|
|
608
|
+
|
|
609
|
+
# Net Income
|
|
610
|
+
total_income = data[:sections][:income][:total] || 0
|
|
611
|
+
total_expenses = data[:sections][:expenses][:total] || 0
|
|
612
|
+
avg_net_income = (total_income - total_expenses) / months_count.to_f
|
|
613
|
+
|
|
614
|
+
puts "%-40s %20s" % ["NET INCOME", format_currency(avg_net_income, currency)]
|
|
615
|
+
puts "=" * 120
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def output_detailed_monthly(data, currency)
|
|
619
|
+
start_date = @report.start_date
|
|
620
|
+
end_date = @report.end_date
|
|
621
|
+
|
|
622
|
+
# Generate list of months
|
|
623
|
+
months = []
|
|
624
|
+
current_date = start_date
|
|
625
|
+
while current_date <= end_date
|
|
626
|
+
months << current_date.strftime("%b %Y")
|
|
627
|
+
current_date = current_date >> 1
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# Determine column width based on number of months
|
|
631
|
+
col_width = [15, 120 / (months.length + 1)].max
|
|
632
|
+
total_width = 40 + (col_width * months.length)
|
|
633
|
+
|
|
634
|
+
# Headers
|
|
635
|
+
header_format = "%-40s" + (" %#{col_width-1}s" * months.length)
|
|
636
|
+
puts header_format % (["Item"] + months)
|
|
637
|
+
puts "-" * total_width
|
|
638
|
+
|
|
639
|
+
# Income section
|
|
640
|
+
if data[:sections][:income]
|
|
641
|
+
puts "\n#{data[:sections][:income][:name]}"
|
|
642
|
+
puts "-" * total_width
|
|
643
|
+
output_detailed_section_items(data[:sections][:income], months.length, col_width)
|
|
644
|
+
|
|
645
|
+
# Total income
|
|
646
|
+
income_total = data[:sections][:income][:total] || 0
|
|
647
|
+
monthly_income = income_total / 12.0
|
|
648
|
+
values = ["TOTAL INCOME"] + [format_currency(monthly_income, currency)] * months.length
|
|
649
|
+
puts (header_format % values)
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
puts ""
|
|
653
|
+
|
|
654
|
+
# Expense section
|
|
655
|
+
if data[:sections][:expenses]
|
|
656
|
+
puts "\n#{data[:sections][:expenses][:name]}"
|
|
657
|
+
puts "-" * total_width
|
|
658
|
+
output_detailed_section_items(data[:sections][:expenses], months.length, col_width)
|
|
659
|
+
|
|
660
|
+
# Total expenses
|
|
661
|
+
expense_total = data[:sections][:expenses][:total] || 0
|
|
662
|
+
monthly_expense = expense_total / 12.0
|
|
663
|
+
values = ["TOTAL EXPENSES"] + [format_currency(monthly_expense, currency)] * months.length
|
|
664
|
+
puts (header_format % values)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
puts ""
|
|
668
|
+
puts "-" * total_width
|
|
669
|
+
|
|
670
|
+
# Net Income
|
|
671
|
+
total_income = data[:sections][:income][:total] || 0
|
|
672
|
+
total_expenses = data[:sections][:expenses][:total] || 0
|
|
673
|
+
monthly_net = (total_income - total_expenses) / 12.0
|
|
674
|
+
values = ["NET INCOME"] + [format_currency(monthly_net, currency)] * months.length
|
|
675
|
+
puts (header_format % values)
|
|
676
|
+
puts "=" * 120
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def output_monthly_section(section, currency, months_count, label)
|
|
680
|
+
puts "\n#{label}"
|
|
681
|
+
puts "-" * 120
|
|
682
|
+
|
|
683
|
+
if section[:items]
|
|
684
|
+
section[:items].each do |item|
|
|
685
|
+
output_monthly_item(item, currency, months_count)
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
avg_total = section[:total] ? section[:total] / months_count.to_f : 0
|
|
690
|
+
puts "%-40s %20s" % ["Total #{section[:name]}", format_currency(avg_total, currency)]
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def output_monthly_item(item, currency, months_count, indent = 0)
|
|
694
|
+
prefix = " " * indent
|
|
695
|
+
display_name = item[:display_name] || item[:name]
|
|
696
|
+
|
|
697
|
+
if item[:subcategories]
|
|
698
|
+
puts "%-40s" % ["#{prefix}#{display_name}:"]
|
|
699
|
+
item[:subcategories].each { |sub| output_monthly_item(sub, currency, months_count, indent + 1) }
|
|
700
|
+
else
|
|
701
|
+
avg_value = item[:value] ? item[:value] / months_count.to_f : 0
|
|
702
|
+
puts "%-40s %20s" % ["#{prefix}#{display_name}", format_currency(avg_value, currency)]
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def output_detailed_section_items(section, months_count, col_width, start_date = nil)
|
|
707
|
+
currency = @report.output_currency
|
|
708
|
+
header_format = "%-40s" + (" %#{col_width-1}s" * months_count)
|
|
709
|
+
start_date ||= @report.start_date
|
|
710
|
+
|
|
711
|
+
if section[:items]
|
|
712
|
+
section[:items].each do |item|
|
|
713
|
+
output_detailed_item_row(item, months_count, col_width, header_format, currency, start_date)
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def output_detailed_item_row(item, months_count, col_width, format_str, currency, start_date = nil, indent = 0)
|
|
719
|
+
prefix = " " * indent
|
|
720
|
+
display_name = item[:display_name] || item[:name]
|
|
721
|
+
start_date ||= @report.start_date
|
|
722
|
+
|
|
723
|
+
if item[:subcategories]
|
|
724
|
+
# Parent category
|
|
725
|
+
puts "#{prefix}#{display_name}:"
|
|
726
|
+
item[:subcategories].each do |sub|
|
|
727
|
+
output_detailed_item_row(sub, months_count, col_width, format_str, currency, start_date, indent + 1)
|
|
728
|
+
end
|
|
729
|
+
else
|
|
730
|
+
# Leaf item - use report data or model methods
|
|
731
|
+
# For monthly breakdown, we should use the report's monthly data if available
|
|
732
|
+
# Otherwise, fall back to dividing annual value by 12
|
|
733
|
+
monthly_values = []
|
|
734
|
+
current_date = start_date
|
|
735
|
+
|
|
736
|
+
months_count.times do
|
|
737
|
+
# Try to get monthly value from report data structure
|
|
738
|
+
# If not available, divide annual value by 12
|
|
739
|
+
month_value = if item[:value]
|
|
740
|
+
item[:value] / 12.0
|
|
741
|
+
else
|
|
742
|
+
0
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
monthly_values << format_currency(month_value, currency)
|
|
746
|
+
current_date = current_date >> 1 # Next month
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
values = ["#{prefix}#{display_name}"] + monthly_values
|
|
750
|
+
puts (format_str % values)
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def output_income_statement_periods_columns(periods, header_format, currency, col_width)
|
|
755
|
+
# Check if we're in separate_cogs mode by looking at first period
|
|
756
|
+
first_period_data = periods.first
|
|
757
|
+
first_report_data = first_period_data[:report] || first_period_data
|
|
758
|
+
separate_cogs_mode = first_report_data[:expense_display_mode] == :separate_cogs
|
|
759
|
+
|
|
760
|
+
# Collect display items from first period
|
|
761
|
+
display_items = first_report_data[:display_items] || []
|
|
762
|
+
|
|
763
|
+
# Collect all unique items across periods
|
|
764
|
+
income_items = {}
|
|
765
|
+
cogs_items = {}
|
|
766
|
+
expense_items = {}
|
|
767
|
+
|
|
768
|
+
periods.each_with_index do |period_data, idx|
|
|
769
|
+
report_data = period_data[:report] || period_data
|
|
770
|
+
sections = report_data[:sections] || {}
|
|
771
|
+
|
|
772
|
+
# Income items
|
|
773
|
+
if sections[:income] && sections[:income][:items]
|
|
774
|
+
sections[:income][:items].each do |item|
|
|
775
|
+
collect_items_for_periods(item, income_items, idx, currency, :income, nil)
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# COGS items (if separate_cogs mode)
|
|
780
|
+
if separate_cogs_mode && sections[:cogs] && sections[:cogs][:items]
|
|
781
|
+
sections[:cogs][:items].each do |item|
|
|
782
|
+
collect_items_for_periods(item, cogs_items, idx, currency, :cogs, nil)
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Expense items (operating expenses in separate_cogs mode, all expenses otherwise)
|
|
787
|
+
if sections[:expenses] && sections[:expenses][:items]
|
|
788
|
+
sections[:expenses][:items].each do |item|
|
|
789
|
+
collect_items_for_periods(item, expense_items, idx, currency, :expense, nil)
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Output display items before income section
|
|
795
|
+
output_display_items_periods(display_items, :before, :income, periods, header_format, currency)
|
|
796
|
+
|
|
797
|
+
# Output income section
|
|
798
|
+
puts "\nINCOME"
|
|
799
|
+
puts "-" * (40 + col_width * periods.length)
|
|
800
|
+
output_items_periods_columns(income_items, periods, header_format, currency, col_width)
|
|
801
|
+
|
|
802
|
+
# Output income total
|
|
803
|
+
puts "-" * (40 + col_width * periods.length)
|
|
804
|
+
income_total_row = ["TOTAL INCOME"]
|
|
805
|
+
periods.each do |period_data|
|
|
806
|
+
report_data = period_data[:report] || period_data
|
|
807
|
+
sections = report_data[:sections] || {}
|
|
808
|
+
income_total = sections[:income] ? (sections[:income][:total] || 0) : 0
|
|
809
|
+
income_total_row << format_currency(income_total, currency)
|
|
810
|
+
end
|
|
811
|
+
puts header_format % income_total_row
|
|
812
|
+
|
|
813
|
+
# Output display items after income section
|
|
814
|
+
output_display_items_periods(display_items, :after, :income, periods, header_format, currency)
|
|
815
|
+
|
|
816
|
+
# Output COGS section (if separate_cogs mode)
|
|
817
|
+
if separate_cogs_mode && cogs_items.any?
|
|
818
|
+
output_display_items_periods(display_items, :before, :cogs, periods, header_format, currency)
|
|
819
|
+
puts "\nCOST OF GOODS SOLD"
|
|
820
|
+
puts "-" * (40 + col_width * periods.length)
|
|
821
|
+
output_items_periods_columns(cogs_items, periods, header_format, currency, col_width)
|
|
822
|
+
|
|
823
|
+
# Output COGS total
|
|
824
|
+
puts "-" * (40 + col_width * periods.length)
|
|
825
|
+
cogs_total_row = ["TOTAL COST OF GOODS SOLD"]
|
|
826
|
+
periods.each do |period_data|
|
|
827
|
+
report_data = period_data[:report] || period_data
|
|
828
|
+
sections = report_data[:sections] || {}
|
|
829
|
+
cogs_total = sections[:cogs] ? (sections[:cogs][:total] || 0) : 0
|
|
830
|
+
cogs_total_row << format_currency(cogs_total, currency)
|
|
831
|
+
end
|
|
832
|
+
puts header_format % cogs_total_row
|
|
833
|
+
|
|
834
|
+
# Output Gross Margin
|
|
835
|
+
puts "-" * (40 + col_width * periods.length)
|
|
836
|
+
gross_margin_row = ["GROSS MARGIN"]
|
|
837
|
+
periods.each do |period_data|
|
|
838
|
+
report_data = period_data[:report] || period_data
|
|
839
|
+
sections = report_data[:sections] || {}
|
|
840
|
+
if sections[:gross_margin]
|
|
841
|
+
gross_margin = sections[:gross_margin][:total] || sections[:gross_margin][:value] || 0
|
|
842
|
+
gross_margin_row << format_currency(gross_margin, currency)
|
|
843
|
+
else
|
|
844
|
+
gross_margin_row << format_currency(0, currency)
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
puts header_format % gross_margin_row
|
|
848
|
+
|
|
849
|
+
# Output display items after gross margin
|
|
850
|
+
output_display_items_periods(display_items, :after, :gross_margin, periods, header_format, currency)
|
|
851
|
+
# Output display items in column next to gross margin
|
|
852
|
+
output_display_items_periods_column(display_items, :gross_margin, periods, header_format, currency)
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Output expense section
|
|
856
|
+
output_display_items_periods(display_items, :before, :expenses, periods, header_format, currency)
|
|
857
|
+
section_label = separate_cogs_mode ? "OPERATING EXPENSES" : "EXPENSES"
|
|
858
|
+
puts "\n#{section_label}"
|
|
859
|
+
puts "-" * (40 + col_width * periods.length)
|
|
860
|
+
output_items_periods_columns(expense_items, periods, header_format, currency, col_width)
|
|
861
|
+
|
|
862
|
+
# Output expense total
|
|
863
|
+
puts "-" * (40 + col_width * periods.length)
|
|
864
|
+
expense_total_label = separate_cogs_mode ? "TOTAL OPERATING EXPENSES" : "TOTAL EXPENSES"
|
|
865
|
+
expense_total_row = [expense_total_label]
|
|
866
|
+
periods.each do |period_data|
|
|
867
|
+
report_data = period_data[:report] || period_data
|
|
868
|
+
sections = report_data[:sections] || {}
|
|
869
|
+
expense_total = sections[:expenses] ? (sections[:expenses][:total] || 0) : 0
|
|
870
|
+
expense_total_row << format_currency(expense_total, currency)
|
|
871
|
+
end
|
|
872
|
+
puts header_format % expense_total_row
|
|
873
|
+
output_display_items_periods(display_items, :after, :expenses, periods, header_format, currency)
|
|
874
|
+
|
|
875
|
+
# Output totals row
|
|
876
|
+
output_display_items_periods(display_items, :before, :net_income, periods, header_format, currency)
|
|
877
|
+
puts "-" * (40 + col_width * periods.length)
|
|
878
|
+
totals_row = ["NET INCOME"]
|
|
879
|
+
periods.each do |period_data|
|
|
880
|
+
report_data = period_data[:report] || period_data
|
|
881
|
+
totals = report_data[:totals] || {}
|
|
882
|
+
income = totals[:income] || 0
|
|
883
|
+
expenses = totals[:expenses] || 0
|
|
884
|
+
net_income = income - expenses
|
|
885
|
+
totals_row << format_currency(net_income, currency)
|
|
886
|
+
end
|
|
887
|
+
puts header_format % totals_row
|
|
888
|
+
output_display_items_periods(display_items, :after, :net_income, periods, header_format, currency)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def output_display_items_periods(display_items, position_type, section_key, periods, header_format, currency)
|
|
892
|
+
items = display_items.select { |item| item[position_type] == section_key }
|
|
893
|
+
return if items.empty?
|
|
894
|
+
|
|
895
|
+
# Sort by order
|
|
896
|
+
items = items.sort_by { |item| item[:order] || 0 }
|
|
897
|
+
|
|
898
|
+
items.each do |item|
|
|
899
|
+
row = [item[:label]]
|
|
900
|
+
|
|
901
|
+
periods.each_with_index do |period_data, idx|
|
|
902
|
+
report_data = period_data[:report] || period_data
|
|
903
|
+
period_display_items = report_data[:display_items] || []
|
|
904
|
+
|
|
905
|
+
# Find this item in the period's display items
|
|
906
|
+
period_item = period_display_items.find { |di| di[:name] == item[:name] }
|
|
907
|
+
|
|
908
|
+
if period_item
|
|
909
|
+
value_str = format_display_value(period_item[:value], period_item[:format], currency)
|
|
910
|
+
row << value_str
|
|
911
|
+
else
|
|
912
|
+
row << format_display_value(0, item[:format], currency)
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
puts header_format % row
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def output_display_items_periods_column(display_items, section_key, periods, header_format, currency)
|
|
921
|
+
items = display_items.select { |item| item[:column] == section_key }
|
|
922
|
+
return if items.empty?
|
|
923
|
+
|
|
924
|
+
# Sort by order
|
|
925
|
+
items = items.sort_by { |item| item[:order] || 0 }
|
|
926
|
+
|
|
927
|
+
items.each do |item|
|
|
928
|
+
row = [" #{item[:label]}"]
|
|
929
|
+
|
|
930
|
+
periods.each_with_index do |period_data, idx|
|
|
931
|
+
report_data = period_data[:report] || period_data
|
|
932
|
+
period_display_items = report_data[:display_items] || []
|
|
933
|
+
|
|
934
|
+
# Find this item in the period's display items
|
|
935
|
+
period_item = period_display_items.find { |di| di[:name] == item[:name] }
|
|
936
|
+
|
|
937
|
+
if item[:last_column_only]
|
|
938
|
+
# Only show in last column
|
|
939
|
+
if idx == periods.length - 1 && period_item
|
|
940
|
+
value_str = format_display_value(period_item[:value], period_item[:format], currency)
|
|
941
|
+
row << value_str
|
|
942
|
+
else
|
|
943
|
+
row << ""
|
|
944
|
+
end
|
|
945
|
+
else
|
|
946
|
+
# Show in all columns
|
|
947
|
+
if period_item
|
|
948
|
+
value_str = format_display_value(period_item[:value], period_item[:format], currency)
|
|
949
|
+
row << value_str
|
|
950
|
+
else
|
|
951
|
+
row << format_display_value(0, item[:format], currency)
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
puts header_format % row
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def output_balance_sheet_periods_columns(periods, header_format, currency, col_width)
|
|
961
|
+
# Collect all unique items across periods
|
|
962
|
+
all_items = {}
|
|
963
|
+
|
|
964
|
+
periods.each_with_index do |period_data, idx|
|
|
965
|
+
report_data = period_data[:report] || period_data
|
|
966
|
+
sections = report_data[:sections] || {}
|
|
967
|
+
|
|
968
|
+
# Asset items
|
|
969
|
+
if sections[:assets] && sections[:assets][:items]
|
|
970
|
+
sections[:assets][:items].each do |item|
|
|
971
|
+
collect_items_for_periods(item, all_items, idx, currency, :asset, nil)
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Liability items
|
|
976
|
+
if sections[:liabilities] && sections[:liabilities][:items]
|
|
977
|
+
sections[:liabilities][:items].each do |item|
|
|
978
|
+
collect_items_for_periods(item, all_items, idx, currency, :liability, nil)
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# Equity items
|
|
983
|
+
if sections[:equity] && sections[:equity][:items]
|
|
984
|
+
sections[:equity][:items].each do |item|
|
|
985
|
+
collect_items_for_periods(item, all_items, idx, currency, :equity, nil)
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Accumulated Net Income items
|
|
990
|
+
if sections[:accumulated_net_income] && sections[:accumulated_net_income][:items]
|
|
991
|
+
sections[:accumulated_net_income][:items].each do |item|
|
|
992
|
+
collect_items_for_periods(item, all_items, idx, currency, :accumulated_net_income, nil)
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
# Output assets section
|
|
998
|
+
puts "\nASSETS"
|
|
999
|
+
puts "-" * (40 + col_width * periods.length)
|
|
1000
|
+
output_items_periods_columns(all_items.select { |k, v| v[:type] == :asset }, periods, header_format, currency, col_width)
|
|
1001
|
+
|
|
1002
|
+
# Output liabilities section
|
|
1003
|
+
puts "\nLIABILITIES"
|
|
1004
|
+
puts "-" * (40 + col_width * periods.length)
|
|
1005
|
+
output_items_periods_columns(all_items.select { |k, v| v[:type] == :liability }, periods, header_format, currency, col_width)
|
|
1006
|
+
|
|
1007
|
+
# Output equity section
|
|
1008
|
+
puts "\nEQUITY"
|
|
1009
|
+
puts "-" * (40 + col_width * periods.length)
|
|
1010
|
+
output_items_periods_columns(all_items.select { |k, v| v[:type] == :equity }, periods, header_format, currency, col_width)
|
|
1011
|
+
|
|
1012
|
+
# Output accumulated net income section
|
|
1013
|
+
accumulated_net_income_items = all_items.select { |k, v| v[:type] == :accumulated_net_income }
|
|
1014
|
+
if accumulated_net_income_items.any?
|
|
1015
|
+
puts "\nACCUMULATED NET INCOME"
|
|
1016
|
+
puts "-" * (40 + col_width * periods.length)
|
|
1017
|
+
output_items_periods_columns(accumulated_net_income_items, periods, header_format, currency, col_width)
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
def output_cash_flow_statement_periods_columns(periods, header_format, currency, col_width)
|
|
1022
|
+
# Check display mode from first period
|
|
1023
|
+
first_period_data = periods.first
|
|
1024
|
+
first_report_data = first_period_data[:report] || first_period_data
|
|
1025
|
+
display_mode = first_report_data[:display_mode] || :standard
|
|
1026
|
+
|
|
1027
|
+
if display_mode == :simple
|
|
1028
|
+
output_simple_cash_flow_periods_columns(periods, header_format, currency, col_width)
|
|
1029
|
+
else
|
|
1030
|
+
output_standard_cash_flow_periods_columns(periods, header_format, currency, col_width)
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
def output_standard_cash_flow_periods_columns(periods, header_format, currency, col_width)
|
|
1035
|
+
line_width = 40 + col_width * periods.length
|
|
1036
|
+
|
|
1037
|
+
# Operating Activities
|
|
1038
|
+
puts "\nOPERATING ACTIVITIES"
|
|
1039
|
+
puts "-" * line_width
|
|
1040
|
+
output_cash_flow_section_items_periods(:operating, periods, header_format, currency)
|
|
1041
|
+
puts "-" * line_width
|
|
1042
|
+
operating_row = ["Net Cash from Operating Activities"]
|
|
1043
|
+
periods.each do |period_data|
|
|
1044
|
+
report_data = period_data[:report] || period_data
|
|
1045
|
+
sections = report_data[:sections] || {}
|
|
1046
|
+
operating_total = sections[:operating] ? (sections[:operating][:total] || 0) : 0
|
|
1047
|
+
operating_row << format_currency(operating_total, currency)
|
|
1048
|
+
end
|
|
1049
|
+
puts header_format % operating_row
|
|
1050
|
+
|
|
1051
|
+
# Investing Activities
|
|
1052
|
+
puts "\nINVESTING ACTIVITIES"
|
|
1053
|
+
puts "-" * line_width
|
|
1054
|
+
output_cash_flow_section_items_periods(:investing, periods, header_format, currency)
|
|
1055
|
+
puts "-" * line_width
|
|
1056
|
+
investing_row = ["Net Cash from Investing Activities"]
|
|
1057
|
+
periods.each do |period_data|
|
|
1058
|
+
report_data = period_data[:report] || period_data
|
|
1059
|
+
sections = report_data[:sections] || {}
|
|
1060
|
+
investing_total = sections[:investing] ? (sections[:investing][:total] || 0) : 0
|
|
1061
|
+
investing_row << format_currency(investing_total, currency)
|
|
1062
|
+
end
|
|
1063
|
+
puts header_format % investing_row
|
|
1064
|
+
|
|
1065
|
+
# Financing Activities
|
|
1066
|
+
puts "\nFINANCING ACTIVITIES"
|
|
1067
|
+
puts "-" * line_width
|
|
1068
|
+
output_cash_flow_section_items_periods(:financing, periods, header_format, currency)
|
|
1069
|
+
puts "-" * line_width
|
|
1070
|
+
financing_row = ["Net Cash from Financing Activities"]
|
|
1071
|
+
periods.each do |period_data|
|
|
1072
|
+
report_data = period_data[:report] || period_data
|
|
1073
|
+
sections = report_data[:sections] || {}
|
|
1074
|
+
financing_total = sections[:financing] ? (sections[:financing][:total] || 0) : 0
|
|
1075
|
+
financing_row << format_currency(financing_total, currency)
|
|
1076
|
+
end
|
|
1077
|
+
puts header_format % financing_row
|
|
1078
|
+
|
|
1079
|
+
# Summary section
|
|
1080
|
+
puts "\n"
|
|
1081
|
+
puts "-" * line_width
|
|
1082
|
+
|
|
1083
|
+
# Net Change in Cash
|
|
1084
|
+
net_change_row = ["NET CHANGE IN CASH"]
|
|
1085
|
+
periods.each do |period_data|
|
|
1086
|
+
report_data = period_data[:report] || period_data
|
|
1087
|
+
sections = report_data[:sections] || {}
|
|
1088
|
+
net_change = sections[:net_change_in_cash] ? (sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0) : 0
|
|
1089
|
+
net_change_row << format_currency(net_change, currency)
|
|
1090
|
+
end
|
|
1091
|
+
puts header_format % net_change_row
|
|
1092
|
+
|
|
1093
|
+
# Beginning Cash Balance
|
|
1094
|
+
beginning_row = ["Beginning Cash Balance"]
|
|
1095
|
+
periods.each do |period_data|
|
|
1096
|
+
report_data = period_data[:report] || period_data
|
|
1097
|
+
sections = report_data[:sections] || {}
|
|
1098
|
+
beginning_cash = sections[:beginning_cash] ? (sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0) : 0
|
|
1099
|
+
beginning_row << format_currency(beginning_cash, currency)
|
|
1100
|
+
end
|
|
1101
|
+
puts header_format % beginning_row
|
|
1102
|
+
|
|
1103
|
+
# Ending Cash Balance
|
|
1104
|
+
ending_row = ["ENDING CASH BALANCE"]
|
|
1105
|
+
periods.each do |period_data|
|
|
1106
|
+
report_data = period_data[:report] || period_data
|
|
1107
|
+
sections = report_data[:sections] || {}
|
|
1108
|
+
ending_cash = sections[:ending_cash] ? (sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0) : 0
|
|
1109
|
+
ending_row << format_currency(ending_cash, currency)
|
|
1110
|
+
end
|
|
1111
|
+
puts header_format % ending_row
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
def output_simple_cash_flow_periods_columns(periods, header_format, currency, col_width)
|
|
1115
|
+
line_width = 40 + col_width * periods.length
|
|
1116
|
+
|
|
1117
|
+
# Cash Inflows
|
|
1118
|
+
puts "\nCASH INFLOWS"
|
|
1119
|
+
puts "-" * line_width
|
|
1120
|
+
output_cash_flow_section_items_periods(:cash_in, periods, header_format, currency)
|
|
1121
|
+
puts "-" * line_width
|
|
1122
|
+
cash_in_row = ["Total Cash Inflows"]
|
|
1123
|
+
periods.each do |period_data|
|
|
1124
|
+
report_data = period_data[:report] || period_data
|
|
1125
|
+
sections = report_data[:sections] || {}
|
|
1126
|
+
cash_in_total = sections[:cash_in] ? (sections[:cash_in][:total] || 0) : 0
|
|
1127
|
+
cash_in_row << format_currency(cash_in_total, currency)
|
|
1128
|
+
end
|
|
1129
|
+
puts header_format % cash_in_row
|
|
1130
|
+
|
|
1131
|
+
# Cash Outflows
|
|
1132
|
+
puts "\nCASH OUTFLOWS"
|
|
1133
|
+
puts "-" * line_width
|
|
1134
|
+
output_cash_flow_section_items_periods(:cash_out, periods, header_format, currency)
|
|
1135
|
+
puts "-" * line_width
|
|
1136
|
+
cash_out_row = ["Total Cash Outflows"]
|
|
1137
|
+
periods.each do |period_data|
|
|
1138
|
+
report_data = period_data[:report] || period_data
|
|
1139
|
+
sections = report_data[:sections] || {}
|
|
1140
|
+
cash_out_total = sections[:cash_out] ? (sections[:cash_out][:total] || 0) : 0
|
|
1141
|
+
cash_out_row << format_currency(cash_out_total, currency)
|
|
1142
|
+
end
|
|
1143
|
+
puts header_format % cash_out_row
|
|
1144
|
+
|
|
1145
|
+
# Summary section
|
|
1146
|
+
puts "\n"
|
|
1147
|
+
puts "-" * line_width
|
|
1148
|
+
|
|
1149
|
+
# Net Change in Cash
|
|
1150
|
+
net_change_row = ["NET CHANGE IN CASH"]
|
|
1151
|
+
periods.each do |period_data|
|
|
1152
|
+
report_data = period_data[:report] || period_data
|
|
1153
|
+
sections = report_data[:sections] || {}
|
|
1154
|
+
net_change = sections[:net_change_in_cash] ? (sections[:net_change_in_cash][:total] || sections[:net_change_in_cash][:value] || 0) : 0
|
|
1155
|
+
net_change_row << format_currency(net_change, currency)
|
|
1156
|
+
end
|
|
1157
|
+
puts header_format % net_change_row
|
|
1158
|
+
|
|
1159
|
+
# Beginning Cash Balance
|
|
1160
|
+
beginning_row = ["Beginning Cash Balance"]
|
|
1161
|
+
periods.each do |period_data|
|
|
1162
|
+
report_data = period_data[:report] || period_data
|
|
1163
|
+
sections = report_data[:sections] || {}
|
|
1164
|
+
beginning_cash = sections[:beginning_cash] ? (sections[:beginning_cash][:total] || sections[:beginning_cash][:value] || 0) : 0
|
|
1165
|
+
beginning_row << format_currency(beginning_cash, currency)
|
|
1166
|
+
end
|
|
1167
|
+
puts header_format % beginning_row
|
|
1168
|
+
|
|
1169
|
+
# Ending Cash Balance
|
|
1170
|
+
ending_row = ["ENDING CASH BALANCE"]
|
|
1171
|
+
periods.each do |period_data|
|
|
1172
|
+
report_data = period_data[:report] || period_data
|
|
1173
|
+
sections = report_data[:sections] || {}
|
|
1174
|
+
ending_cash = sections[:ending_cash] ? (sections[:ending_cash][:total] || sections[:ending_cash][:value] || 0) : 0
|
|
1175
|
+
ending_row << format_currency(ending_cash, currency)
|
|
1176
|
+
end
|
|
1177
|
+
puts header_format % ending_row
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def output_cash_flow_section_items_periods(section_key, periods, header_format, currency)
|
|
1181
|
+
# Collect all unique items across periods for this section
|
|
1182
|
+
all_items = {}
|
|
1183
|
+
|
|
1184
|
+
periods.each_with_index do |period_data, idx|
|
|
1185
|
+
report_data = period_data[:report] || period_data
|
|
1186
|
+
sections = report_data[:sections] || {}
|
|
1187
|
+
|
|
1188
|
+
if sections[section_key] && sections[section_key][:items]
|
|
1189
|
+
sections[section_key][:items].each do |item|
|
|
1190
|
+
item_name = item[:name] || item[:display_name]
|
|
1191
|
+
unless all_items[item_name]
|
|
1192
|
+
all_items[item_name] = {
|
|
1193
|
+
name: item[:display_name] || item[:name],
|
|
1194
|
+
values: []
|
|
1195
|
+
}
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
# Ensure values array is long enough
|
|
1199
|
+
while all_items[item_name][:values].length < idx
|
|
1200
|
+
all_items[item_name][:values] << 0
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
all_items[item_name][:values][idx] = item[:value] || 0
|
|
1204
|
+
end
|
|
1205
|
+
end
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
# Output each item
|
|
1209
|
+
all_items.each do |_key, item_data|
|
|
1210
|
+
row = [" #{item_data[:name]}"]
|
|
1211
|
+
|
|
1212
|
+
# Ensure we have values for all periods
|
|
1213
|
+
while item_data[:values].length < periods.length
|
|
1214
|
+
item_data[:values] << 0
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
item_data[:values].each do |val|
|
|
1218
|
+
row << format_currency(val || 0, currency)
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
puts header_format % row
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
def collect_items_for_periods(item, all_items, period_idx, currency, type = nil, parent_key = nil)
|
|
1226
|
+
key = item[:name] || item[:display_name]
|
|
1227
|
+
type ||= :income # default for income statements
|
|
1228
|
+
|
|
1229
|
+
# Initialize if first time seeing this item
|
|
1230
|
+
unless all_items[key]
|
|
1231
|
+
all_items[key] = {
|
|
1232
|
+
name: item[:display_name] || item[:name],
|
|
1233
|
+
indent: item[:indent] || 0,
|
|
1234
|
+
type: type,
|
|
1235
|
+
values: [],
|
|
1236
|
+
parent: parent_key,
|
|
1237
|
+
children: []
|
|
1238
|
+
}
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# Track parent-child relationship
|
|
1242
|
+
if parent_key && !all_items[parent_key][:children].include?(key)
|
|
1243
|
+
all_items[parent_key][:children] << key
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
# Ensure values array is long enough
|
|
1247
|
+
while all_items[key][:values].length < period_idx
|
|
1248
|
+
all_items[key][:values] << nil
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# Set value for this period
|
|
1252
|
+
value = item[:value] || 0
|
|
1253
|
+
all_items[key][:values][period_idx] = value
|
|
1254
|
+
|
|
1255
|
+
# Handle subcategories recursively
|
|
1256
|
+
if item[:subcategories] && item[:subcategories].any?
|
|
1257
|
+
item[:subcategories].each do |sub|
|
|
1258
|
+
collect_items_for_periods(sub, all_items, period_idx, currency, type, key)
|
|
1259
|
+
end
|
|
1260
|
+
end
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
def output_items_periods_columns(items_hash, periods, header_format, currency, col_width)
|
|
1264
|
+
# Find root items (items without parents)
|
|
1265
|
+
root_items = items_hash.select { |k, v| v[:parent].nil? }.keys
|
|
1266
|
+
|
|
1267
|
+
# Output items in hierarchical order with recursion protection
|
|
1268
|
+
visited = Set.new
|
|
1269
|
+
root_items.each do |root_key|
|
|
1270
|
+
output_item_hierarchical(root_key, items_hash, periods, header_format, currency, col_width, visited, 0)
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
def output_item_hierarchical(item_key, items_hash, periods, header_format, currency, col_width, visited = Set.new, depth = 0)
|
|
1275
|
+
# Protect against infinite recursion
|
|
1276
|
+
return if visited.include?(item_key)
|
|
1277
|
+
return if depth > 50 # Maximum depth limit
|
|
1278
|
+
|
|
1279
|
+
item_data = items_hash[item_key]
|
|
1280
|
+
return unless item_data
|
|
1281
|
+
|
|
1282
|
+
# Mark as visited
|
|
1283
|
+
visited.add(item_key)
|
|
1284
|
+
|
|
1285
|
+
# Output this item
|
|
1286
|
+
prefix = " " * (item_data[:indent] || 0)
|
|
1287
|
+
values = ["#{prefix}#{item_data[:name]}"]
|
|
1288
|
+
|
|
1289
|
+
# Ensure we have values for all periods
|
|
1290
|
+
while item_data[:values].length < periods.length
|
|
1291
|
+
item_data[:values] << nil
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
# All values are stored as positive
|
|
1295
|
+
item_data[:values].each do |val|
|
|
1296
|
+
display_val = val || 0
|
|
1297
|
+
values << format_currency(display_val, currency)
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
puts header_format % values
|
|
1301
|
+
|
|
1302
|
+
# Output children items recursively
|
|
1303
|
+
if item_data[:children] && item_data[:children].any?
|
|
1304
|
+
item_data[:children].each do |child_key|
|
|
1305
|
+
output_item_hierarchical(child_key, items_hash, periods, header_format, currency, col_width, visited, depth + 1)
|
|
1306
|
+
end
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
# Remove from visited set after processing children (allows re-visiting in different branches)
|
|
1310
|
+
visited.delete(item_key)
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def output_scenario_comparison(data, currency)
|
|
1314
|
+
metadata = data[:metadata]
|
|
1315
|
+
base_metrics = data[:base]
|
|
1316
|
+
scenarios = data[:scenarios]
|
|
1317
|
+
variances = data[:variances]
|
|
1318
|
+
scenario_names = metadata[:scenario_names]
|
|
1319
|
+
|
|
1320
|
+
# Use normalized_metrics if available (has labels)
|
|
1321
|
+
normalized_metrics = metadata[:normalized_metrics] || []
|
|
1322
|
+
|
|
1323
|
+
# Calculate column widths
|
|
1324
|
+
label_width = 30
|
|
1325
|
+
value_width = 18
|
|
1326
|
+
num_scenarios = scenario_names.length
|
|
1327
|
+
total_width = label_width + value_width + (value_width * num_scenarios)
|
|
1328
|
+
|
|
1329
|
+
# Header
|
|
1330
|
+
puts "=" * total_width
|
|
1331
|
+
puts "SCENARIO COMPARISON (#{currency})"
|
|
1332
|
+
puts "Period: #{metadata[:start_date]} to #{metadata[:end_date]}"
|
|
1333
|
+
puts "=" * total_width
|
|
1334
|
+
puts ""
|
|
1335
|
+
|
|
1336
|
+
# Column headers
|
|
1337
|
+
header_format = "%-#{label_width}s %#{value_width}s" + (" %#{value_width}s" * num_scenarios)
|
|
1338
|
+
headers = ["Metric", "Base"] + scenario_names.map { |n| humanize_category_name(n) }
|
|
1339
|
+
puts header_format % headers
|
|
1340
|
+
puts "-" * total_width
|
|
1341
|
+
|
|
1342
|
+
# Output each metric using normalized metrics for labels
|
|
1343
|
+
normalized_metrics.each do |metric_config|
|
|
1344
|
+
metric_key = metric_config[:key]
|
|
1345
|
+
metric_label = metric_config[:label]
|
|
1346
|
+
base_value = base_metrics[metric_key] || 0
|
|
1347
|
+
|
|
1348
|
+
# First row: values
|
|
1349
|
+
values_row = [metric_label, format_currency(base_value, currency)]
|
|
1350
|
+
scenario_names.each do |scenario_name|
|
|
1351
|
+
scenario_metrics = scenarios[scenario_name]
|
|
1352
|
+
scenario_value = scenario_metrics ? (scenario_metrics[metric_key] || 0) : 0
|
|
1353
|
+
values_row << format_currency(scenario_value, currency)
|
|
1354
|
+
end
|
|
1355
|
+
puts header_format % values_row
|
|
1356
|
+
|
|
1357
|
+
# Second row: variance percentages (indented, under scenario columns)
|
|
1358
|
+
variance_row = ["", ""] # Empty for label and base columns
|
|
1359
|
+
scenario_names.each do |scenario_name|
|
|
1360
|
+
if variances[scenario_name]
|
|
1361
|
+
metric_variance = variances[scenario_name][metric_key]
|
|
1362
|
+
if metric_variance
|
|
1363
|
+
pct = metric_variance[:percentage]
|
|
1364
|
+
variance_str = pct >= 0 ? "+#{pct}%" : "#{pct}%"
|
|
1365
|
+
variance_row << variance_str
|
|
1366
|
+
else
|
|
1367
|
+
variance_row << ""
|
|
1368
|
+
end
|
|
1369
|
+
else
|
|
1370
|
+
variance_row << ""
|
|
1371
|
+
end
|
|
1372
|
+
end
|
|
1373
|
+
puts header_format % variance_row
|
|
1374
|
+
|
|
1375
|
+
puts "-" * total_width
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1378
|
+
puts "=" * total_width
|
|
1379
|
+
puts ""
|
|
1380
|
+
puts "Generated at: #{metadata[:generated_at]}"
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
def output_period_comparison(data, currency)
|
|
1384
|
+
metadata = data[:metadata]
|
|
1385
|
+
periods = data[:periods]
|
|
1386
|
+
variances = data[:variances]
|
|
1387
|
+
normalized_metrics = metadata[:normalized_metrics] || []
|
|
1388
|
+
|
|
1389
|
+
# Calculate column widths
|
|
1390
|
+
label_width = 30
|
|
1391
|
+
value_width = 18
|
|
1392
|
+
num_periods = periods.length
|
|
1393
|
+
total_width = label_width + (value_width * num_periods)
|
|
1394
|
+
|
|
1395
|
+
# Header
|
|
1396
|
+
puts "=" * total_width
|
|
1397
|
+
puts "PERIOD COMPARISON (#{currency})"
|
|
1398
|
+
puts "=" * total_width
|
|
1399
|
+
puts ""
|
|
1400
|
+
|
|
1401
|
+
# Column headers
|
|
1402
|
+
header_format = "%-#{label_width}s" + (" %#{value_width}s" * num_periods)
|
|
1403
|
+
headers = ["Metric"] + periods.map { |p| p[:name] }
|
|
1404
|
+
puts header_format % headers
|
|
1405
|
+
puts "-" * total_width
|
|
1406
|
+
|
|
1407
|
+
# Output each metric
|
|
1408
|
+
normalized_metrics.each do |metric_config|
|
|
1409
|
+
metric_key = metric_config[:variable]
|
|
1410
|
+
metric_label = metric_config[:label]
|
|
1411
|
+
|
|
1412
|
+
# First row: values
|
|
1413
|
+
values_row = [metric_label]
|
|
1414
|
+
periods.each do |period|
|
|
1415
|
+
period_value = period[:metrics][metric_key] || 0
|
|
1416
|
+
values_row << format_currency(period_value, currency)
|
|
1417
|
+
end
|
|
1418
|
+
puts header_format % values_row
|
|
1419
|
+
|
|
1420
|
+
# Second row: variance percentages (first period is blank)
|
|
1421
|
+
variance_row = [""]
|
|
1422
|
+
variances.each_with_index do |var_data, idx|
|
|
1423
|
+
if var_data[:variances]
|
|
1424
|
+
metric_variance = var_data[:variances][metric_key]
|
|
1425
|
+
if metric_variance
|
|
1426
|
+
pct = metric_variance[:percentage]
|
|
1427
|
+
variance_str = pct >= 0 ? "+#{pct}%" : "#{pct}%"
|
|
1428
|
+
variance_row << variance_str
|
|
1429
|
+
else
|
|
1430
|
+
variance_row << ""
|
|
1431
|
+
end
|
|
1432
|
+
else
|
|
1433
|
+
variance_row << "" # First period has no variance
|
|
1434
|
+
end
|
|
1435
|
+
end
|
|
1436
|
+
puts header_format % variance_row
|
|
1437
|
+
|
|
1438
|
+
puts "-" * total_width
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
puts "=" * total_width
|
|
1442
|
+
puts ""
|
|
1443
|
+
puts "Generated at: #{metadata[:generated_at]}"
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
def output_custom_sheet(data)
|
|
1447
|
+
title = data[:title]
|
|
1448
|
+
sections = data[:sections]
|
|
1449
|
+
|
|
1450
|
+
puts "=" * 80
|
|
1451
|
+
puts title.upcase.center(80)
|
|
1452
|
+
puts "=" * 80
|
|
1453
|
+
puts ""
|
|
1454
|
+
|
|
1455
|
+
sections.each do |section|
|
|
1456
|
+
case section[:type]
|
|
1457
|
+
when :header
|
|
1458
|
+
level = section[:level] || 1
|
|
1459
|
+
text = section[:text]
|
|
1460
|
+
if level == 1
|
|
1461
|
+
puts text.upcase
|
|
1462
|
+
puts "=" * text.length
|
|
1463
|
+
else
|
|
1464
|
+
puts text
|
|
1465
|
+
puts "-" * text.length
|
|
1466
|
+
end
|
|
1467
|
+
when :paragraph
|
|
1468
|
+
puts section[:text]
|
|
1469
|
+
when :spacer
|
|
1470
|
+
(section[:rows] || 1).times { puts "" }
|
|
1471
|
+
when :key_value
|
|
1472
|
+
puts "#{section[:label]}: #{section[:value]}"
|
|
1473
|
+
when :bullet_list
|
|
1474
|
+
section[:items].each { |item| puts " * #{item}" }
|
|
1475
|
+
when :numbered_list
|
|
1476
|
+
section[:items].each_with_index { |item, idx| puts " #{idx + 1}. #{item}" }
|
|
1477
|
+
when :table
|
|
1478
|
+
output_custom_sheet_table(section)
|
|
1479
|
+
when :hr
|
|
1480
|
+
puts "-" * 80
|
|
1481
|
+
when :note
|
|
1482
|
+
style_prefix = case section[:style]
|
|
1483
|
+
when :warning then "[!] "
|
|
1484
|
+
when :error then "[X] "
|
|
1485
|
+
when :success then "[+] "
|
|
1486
|
+
else "[i] "
|
|
1487
|
+
end
|
|
1488
|
+
puts "#{style_prefix}#{section[:text]}"
|
|
1489
|
+
end
|
|
1490
|
+
puts ""
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
if data[:generated_at]
|
|
1494
|
+
puts "-" * 80
|
|
1495
|
+
puts "Generated at: #{data[:generated_at]}"
|
|
1496
|
+
end
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
def output_custom_sheet_table(section)
|
|
1500
|
+
headers = section[:headers]
|
|
1501
|
+
rows = section[:rows]
|
|
1502
|
+
|
|
1503
|
+
if section[:title]
|
|
1504
|
+
puts section[:title]
|
|
1505
|
+
puts "-" * 60
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# Calculate column widths
|
|
1509
|
+
all_rows = [headers] + rows
|
|
1510
|
+
col_widths = headers.map.with_index do |_, idx|
|
|
1511
|
+
all_rows.map { |row| row[idx].to_s.length }.max
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
# Format string
|
|
1515
|
+
format_str = col_widths.map { |w| "%-#{w + 2}s" }.join(" | ")
|
|
1516
|
+
|
|
1517
|
+
# Output header
|
|
1518
|
+
puts format_str % headers
|
|
1519
|
+
puts "-" * (col_widths.sum + (col_widths.length - 1) * 3 + col_widths.length * 2)
|
|
1520
|
+
|
|
1521
|
+
# Output rows
|
|
1522
|
+
rows.each do |row|
|
|
1523
|
+
puts format_str % row
|
|
1524
|
+
end
|
|
1525
|
+
end
|
|
1526
|
+
end
|
|
1527
|
+
end
|
|
1528
|
+
end
|