diff --git a/.gitignore b/.gitignore
index 99db197..627bc60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,4 @@
/node_modules
.idea/
+TZ.xlsx
diff --git a/app/assets/images/arrow_down.svg b/app/assets/images/arrow_down.svg
new file mode 100755
index 0000000..0afa0a4
--- /dev/null
+++ b/app/assets/images/arrow_down.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/app/assets/images/arrow_up.svg b/app/assets/images/arrow_up.svg
new file mode 100755
index 0000000..4b8db4d
--- /dev/null
+++ b/app/assets/images/arrow_up.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/app/assets/images/favicon.svg b/app/assets/images/favicon.svg
new file mode 100644
index 0000000..b790ba6
--- /dev/null
+++ b/app/assets/images/favicon.svg
@@ -0,0 +1,10 @@
+
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 3e3a59d..127e4d5 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -22,4 +22,5 @@
@use "application_settings";
@use "main";
@use "header";
- @use "nav";
\ No newline at end of file
+ @use "nav";
+ @use "organisation";
\ No newline at end of file
diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss
index b70cad6..06e3dff 100644
--- a/app/assets/stylesheets/main.scss
+++ b/app/assets/stylesheets/main.scss
@@ -34,9 +34,9 @@ a.link:hover {
.informations-data {
display: flex;
flex-direction: column;
- width: 90%;
+ width: 95%;
min-width: 300px;
- max-width: 1400px;
+ max-width: 1500px;
margin: 0 auto;
padding-bottom: inherit;
}
@@ -81,6 +81,14 @@ a.link:hover {
border: 3px solid black;
}
+.information-table tr :is(td, th):first-child {
+ width: 20%;
+}
+
+.description {
+ width: 15%;
+}
+
.type_doc_header#tabs {
width: 75%;
margin: 0 auto;
@@ -100,3 +108,55 @@ a.link:hover {
a.link:has(.active__button) {
background-color: gray;
}
+
+nav.pagy {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-block: 20px;
+ font-size: 1.2rem;
+
+ a {
+ padding: 4px 10px;
+ border: 1px solid black;
+ text-decoration: none;
+ color: inherit;
+
+ &.current { font-weight: 700; }
+ &[aria-disabled="true"] { opacity: 0.4; }
+ &:not([aria-disabled="true"]):hover { background-color: black; color: white; }
+ }
+}
+
+.organisation-actions {
+ display: flex;
+ gap: 12px;
+ margin-block: 16px;
+}
+
+a.link-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid black;
+ border-radius: 20px;
+ padding: 8px 16px;
+ font-size: 1.2rem;
+ text-decoration: none;
+ color: inherit;
+
+ &:hover {
+ background-color: black;
+ color: var(--white-color);
+ }
+
+ &.link-action--danger {
+ border-color: #c0392b;
+ color: #c0392b;
+
+ &:hover {
+ background-color: #c0392b;
+ color: var(--white-color);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/organisation.scss b/app/assets/stylesheets/organisation.scss
new file mode 100644
index 0000000..2f27bfa
--- /dev/null
+++ b/app/assets/stylesheets/organisation.scss
@@ -0,0 +1,19 @@
+.up::before,
+.down::before {
+ content: "";
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ margin-right: 6px;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+}
+
+.up::before {
+ background-image: asset-url('arrow_up.svg');
+}
+
+.down::before {
+ background-image: asset-url('arrow_down.svg');
+}
diff --git a/app/controllers/concerns/Authorization.rb b/app/controllers/concerns/Authorization.rb
index 0b692d4..2b06bd8 100644
--- a/app/controllers/concerns/Authorization.rb
+++ b/app/controllers/concerns/Authorization.rb
@@ -28,18 +28,13 @@ module Authorization
session['cas'] && session['cas']['user']
end
- def set_current_user(options = {})
- return nil if current_login.blank?
- load_current_user
- end
-
def load_current_user
@current_user ||= User.where('lower(login) = lower(?)', current_login).first
end
def check_authentication
if session.blank? || session['cas'].blank? || session['cas']['user'].blank? ||
- (request.get? && !request.xhr? && request.xhr? != 0 && (session['cas']['last_validated_at'].blank? || session['cas']['last_validated_at'] < 15.minutes.ago))
+ (request.get? && !request.xhr? && (session['cas']['last_validated_at'].blank? || session['cas']['last_validated_at'] < 15.minutes.ago))
render plain: 'Требуется авторизация', status: 401
end
end
diff --git a/app/controllers/organisations_controller.rb b/app/controllers/organisations_controller.rb
index 2f2aa53..a4e1160 100644
--- a/app/controllers/organisations_controller.rb
+++ b/app/controllers/organisations_controller.rb
@@ -1,5 +1,5 @@
class OrganisationsController < ApplicationController
- before_action :set_organisation, only: [:show]
+ before_action :set_organisation, :set_report_codes, only: [:show, :destroy, :reload]
DOC_TYPES = {
0 => { title: 'Бухгалтерский баланс', form_code: '1.1' },
@@ -9,7 +9,7 @@ class OrganisationsController < ApplicationController
}.freeze
def index
- organizations = Organization.all
+ @pagy, organizations = pagy(Organization.order(:created_at))
@info_about_organisations = organizations.each_with_object({}) do |org, hash|
hash[org.id] = {
@@ -30,39 +30,62 @@ class OrganisationsController < ApplicationController
statements_by_year_and_form = build_statements_by_year_and_form
years = statements_by_year_and_form.keys.sort.reverse
- grouped_rows = build_grouped_rows(statements_by_year_and_form, years)
-
+ grouped_rows = clean_data(build_grouped_rows(statements_by_year_and_form, years))
@info_about_organisation = build_organization_payload(years)
selected_doc = DOC_TYPES.fetch(@type_doc, DOC_TYPES[0])
@doc_name = selected_doc[:title]
- @current_doc_info = @info_about_organisation[@doc_name]
@current_doc_info1 = grouped_rows[@doc_name]
+ if !@current_doc_info1.values.first.nil?
+ number_of_columns = @current_doc_info1.values.first.size + 1
+ number_of_columns += 1 if @type_doc < 2
+ @current_doc_info = @info_about_organisation[@doc_name][0..number_of_columns]
+ end
end
+ def destroy
+ @org.destroy!
+ redirect_to '/organisations/index', notice: "Организация #{@org.inn} удалена"
+ end
+
+ def reload
+ report = Report.create!(inn: @org.inn, status: 'processing')
+ FileProcessorJob.perform_async(@org.inn, report.id)
+ redirect_to "/organisations/#{@org.id}", notice: 'Данные обновляются в фоне'
+ end
+
private
def set_organisation
@org = Organization.find(params[:id])
end
- def build_organization_payload(years)
- payload = {
- 'Сведения об организации' => {
- 'ИНН' => @org.inn,
- 'Полное наименование юридического лица' => @org.full_name,
- 'КПП' => @org.kpp,
- 'Код по ОКПО' => @org.okpo,
- 'Форма собственности (по ОКФС)' => @org.okfs,
- 'Организационно-правовая форма (по ОКОПФ)' => @org.okopf,
- 'Вид экономической деятельности по ОКВЭД 2' => @org.okved2,
- 'Местонахождение (адрес)' => @org.address
- }
- }
+ def set_report_codes
+ @report_codes = ReportCode.all.filter_map { |report_code| [report_code.code, report_code.description] if report_code.code.match?(/\A[12]/) }.to_h
+ end
+ def build_organization_payload(years)
+ russian_names = {
+ 'inn' => 'ИНН',
+ 'full_name' => 'Полное наименование юридического лица',
+ 'kpp' => 'КПП',
+ 'okpo' => 'Код по ОКПО',
+ 'okfs' => 'Форма собственности (по ОКФС)',
+ 'okopf' => 'Организационно-правовая форма (по ОКОПФ)',
+ 'okved2' => 'Вид экономической деятельности по ОКВЭД 2',
+ 'address' => 'Местонахождение (адрес)'
+ }
+
+ payload = {
+ 'Сведения об организации' => @org.attributes.filter_map { |key, value| [russian_names[key], value] \
+ if !['id', 'created_at', 'updated_at'].include?(key) && !value.nil? }.to_h
+ }
+
DOC_TYPES.each_value do |doc_type|
- payload[doc_type[:title]] = ['Наименование показателя', 'Код'] + years.map do |year|
+ first_headers = ['Наименование показателя', 'Код']
+ first_headers << 'Пояснения' if ['Бухгалтерский баланс', 'Отчёт о финансовых результатах'].include?(doc_type[:title])
+ payload[doc_type[:title]] = first_headers + years.map do |year|
column_header_for(doc_type[:form_code], year)
end
end
@@ -95,6 +118,24 @@ class OrganisationsController < ApplicationController
data
end
+ def clean_data(data)
+ data.transform_values do |doc_type_data|
+ rows = doc_type_data.reject do |_, values|
+ values.all? { |value| value == "-" || value.nil? || value == ""}
+ end
+
+ next {} if rows.empty?
+
+ column_count = rows.values.map(&:size).max
+ columns_to_keep = (0...column_count).select do |i|
+ rows.values.any? { |values| values[i] != "-" && !values[i].nil? && values[i] != ""}
+ end
+ rows.transform_values do |values|
+ columns_to_keep.map { |i| values[i]}
+ end
+ end
+ end
+
def build_statements_by_year_and_form
grouped = Hash.new { |hash, key| hash[key] = {} }
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index accc7a7..dbd227d 100644
--- a/app/controllers/reports_controller.rb
+++ b/app/controllers/reports_controller.rb
@@ -4,9 +4,13 @@ class ReportsController < ApplicationController
end
def create
- inn = params[:inn]
- report = Report.create!(inn: inn, status: 'processing')
+ inn = params[:inn].to_s.strip
+ unless inn.match?(/\A\d{10}(\d{2})?\z/)
+ redirect_to reports_path, alert: 'Некорректный ИНН (должен содержать 10 или 12 цифр)'
+ return
+ end
+ report = Report.create!(inn: inn, status: 'processing')
FileProcessorJob.perform_async(inn, report.id)
redirect_to reports_path, notice: 'Отчёт загружается в фоне'
diff --git a/app/helpers/organisations_helper.rb b/app/helpers/organisations_helper.rb
index 1b84554..9050002 100644
--- a/app/helpers/organisations_helper.rb
+++ b/app/helpers/organisations_helper.rb
@@ -1,2 +1,113 @@
module OrganisationsHelper
+ # Функции для раскраски
+ def group_key(code)
+ return nil if code.nil? || code.size < 2
+ prefix = code[0, 2]
+ case prefix
+ when '21', '22', '23', '24', '25'
+ '21-25'
+ else
+ prefix
+ end
+ end
+
+ def keep_code?(code, type_data: 'min_max')
+ return false if code.nil? || code.size < 2
+ prefix = code[0, 2]
+ return false unless prefix.match?(/\A\d{2}\z/)
+ if type_data == 'min_max'
+ return false if %w[16 17 29].include?(prefix) || prefix.start_with?("3")
+ return true if %w[21 22 23 24 25].include?(prefix)
+ return !code.end_with?('00')
+ elsif type_data == 'comparsion'
+ return code.end_with?('00')
+ end
+ end
+
+ def parse_number(value)
+ return 0 if value.nil? || value.empty? || ['-', '(-)', 'X'].include?(value)
+ negative = value.start_with?('(') && value.end_with?(')')
+ value = value.tr('()', '') if negative
+ value = value.delete(' ')
+ return 0 unless value.match?(/\A\d+\z/)
+ number = value.to_i
+ negative ? -number : number
+ end
+
+ def prepare_data(data, type_data: 'min_max')
+ return {} if data.nil?
+ filtered_data = data.reject do |(_, _), values|
+ values.all? { |value| ['-', '(-)', 'X'].include?(value) }
+ end
+ if type_data == 'min_max'
+ prepared_data = filtered_data.each_with_object({}) do |((_, code), values), result|
+ next if code.nil? || !keep_code?(code)
+ group = group_key(code)
+ result[group] ||= []
+ result[group] += values.map { |value| parse_number(value)}
+ end
+ min_max_data = prepared_data.transform_values do |values|
+ sorted = values.sort
+ size = sorted.size
+ p50 = size.odd? ? sorted[size / 2] : (sorted[size / 2 - 1] + sorted[size / 2]) / 2.0
+ { min: sorted.first, p50: p50, max: sorted.last }
+ end
+ elsif type_data == 'comparsion'
+ prepared_data = filtered_data.each_with_object({}) do |((_, code), values), result|
+ next if code.nil? || !keep_code?(code, type_data: 'comparsion')
+ result[code] ||= []
+ (0...values.size - 1).each do |index|
+ if parse_number(values[index]) == parse_number(values[index + 1])
+ result[code] << nil
+ elsif
+ result[code] << (parse_number(values[index]) > parse_number(values[index + 1]))
+ end
+ end
+ result[code] << nil
+ end
+ end
+ end
+
+ def get_color_for_cell(value, min:, median:, max:)
+ value = parse_number(value)
+ return rgb_to_hex(WHITE) if value.nil? || min.nil? || max.nil? || median.nil?
+ return rgb_to_hex(GREEN) if min == max
+
+ if value <= median
+ ratio = median == min ? 0.0 : (value - min).to_f / (median - min)
+ rgb = interpolate_color(RED, YELLOW, clamp(ratio))
+ else
+ ratio = max == median ? 0.0 : (value - median).to_f / (max - median)
+ rgb = interpolate_color(YELLOW, GREEN, clamp(ratio))
+ end
+
+ rgb_to_hex(rgb)
+ end
+
+ def get_arrow_for_cell(value, condition)
+ return content_tag(:td, value) if condition.nil?
+ css_class = condition ? 'up': 'down'
+ content_tag(:td, value, class: css_class)
+ end
+
+
+ private
+
+ def interpolate_color(from, to, ratio)
+ from.zip(to).map { |a, b| (a + (b - a) * ratio).round }
+ end
+
+ def rgb_to_hex(rgb)
+ format("#%02X%02X%02X", *rgb)
+ end
+
+ def clamp(value)
+ [[value, 0.0].max, 1.0].min
+ end
+
+ RED = [248, 105, 107].freeze
+ YELLOW = [255, 235, 132].freeze
+ GREEN = [99, 190, 123].freeze
+ WHITE = [255, 255, 255].freeze
+
end
diff --git a/app/services/report_parser.rb b/app/services/report_parser.rb
index 62f48a1..7754bdd 100644
--- a/app/services/report_parser.rb
+++ b/app/services/report_parser.rb
@@ -11,7 +11,7 @@ class ReportParser
sheet_name: 'Отчет о финансовых результатах'
},
'1.3' => {
- parser: nil,
+ parser: ReportParsers::Form13Parser,
sheet_name: 'Отчет об изменениях капитала'
},
'1.4' => {
@@ -33,17 +33,19 @@ class ReportParser
parsed_forms = parse_forms
save_aggregated_statements(parsed_forms)
end
+ ensure
+ @tempfile&.close!
end
private
def spreadsheet
@spreadsheet ||= begin
- tempfile = Tempfile.new(['report', '.xlsx'])
- tempfile.binmode
- @yearly_file.file.download { |chunk| tempfile.write(chunk) }
- tempfile.rewind
- Roo::Excelx.new(tempfile.path)
+ @tempfile = Tempfile.new(['report', '.xlsx'])
+ @tempfile.binmode
+ @yearly_file.file.download { |chunk| @tempfile.write(chunk) }
+ @tempfile.rewind
+ Roo::Excelx.new(@tempfile.path)
end
end
@@ -64,10 +66,12 @@ class ReportParser
target_years.each do |year|
statement = find_or_build_statement(year)
if should_update_statement?(statement)
+ previous_data = statement.data.deep_dup if statement.persisted?
+
statement.assign_attributes(
yearly_file: @yearly_file,
sheet_number: year,
- data: build_payload_for_year(year, parsed_forms, codes)
+ data: build_payload_for_year(year, parsed_forms, codes, previous_data: previous_data)
)
statement.save!
end
@@ -82,9 +86,19 @@ class ReportParser
years.sort.reverse
end
- def build_payload_for_year(year, parsed_forms, codes)
+ def build_payload_for_year(year, parsed_forms, codes, previous_data: nil)
forms_payload = FORM_DEFINITIONS.each_with_object({}) do |(form_code, config), hash|
- form_data = parsed_forms.fetch(form_code, {})[year] || {}
+ form_present, form_data = parsed_form_for_year(parsed_forms, form_code, year)
+ form_data ||= {}
+
+ unless form_present
+ previous_payload = previous_form_payload(previous_data, form_code)
+ if previous_payload.present?
+ hash[form_code] = previous_payload
+ next
+ end
+ end
+
source_rows = form_data[:rows] || form_data['rows'] || []
rows_by_code = index_rows_by_code(source_rows)
form_codes = report_codes_for_form(form_code, codes)
@@ -111,6 +125,21 @@ class ReportParser
}
end
+ def parsed_form_for_year(parsed_forms, form_code, year)
+ form_results = parsed_forms.fetch(form_code, {})
+ return [true, form_results[year]] if form_results.key?(year)
+ return [true, form_results[year.to_s]] if form_results.key?(year.to_s)
+
+ [false, {}]
+ end
+
+ def previous_form_payload(previous_data, form_code)
+ forms = previous_data && (previous_data[:forms] || previous_data['forms'])
+ return unless forms.is_a?(Hash)
+
+ forms[form_code] || forms[form_code.to_sym]
+ end
+
def index_rows_by_code(rows)
rows.each_with_object({}) do |row, hash|
normalized_code = normalize_code(row[:line_code] || row['line_code'])
@@ -175,7 +204,7 @@ class ReportParser
end
def report_codes
- @report_codes ||= ReportCode.order(:code).select(:code, :name).to_a
+ @report_codes ||= ReportCode.select(:code, :name).order(:id).to_a
end
def report_codes_for_form(form_code, codes)
@@ -202,7 +231,7 @@ class ReportParser
.where(sheet_number: year)
.where(yearly_files: { organization_id: @yearly_file.organization_id })
.where.not(id: keeper_id)
- .delete_all
+ .destroy_all
end
end
diff --git a/app/services/report_parsers/form11_parser.rb b/app/services/report_parsers/form11_parser.rb
index 1e86f87..bd66e0d 100644
--- a/app/services/report_parsers/form11_parser.rb
+++ b/app/services/report_parsers/form11_parser.rb
@@ -19,10 +19,11 @@ module ReportParsers
columns = detect_columns(sheet, header_row)
- value_headers = columns[:value_columns].each_with_object({}) do |col, hash|
- header_text = read_cell(sheet, header_row, col).to_s
+ # year читается по оригинальному col заголовка, данные — по откалиброванному
+ value_headers = columns[:value_columns].zip(columns[:data_value_columns]).each_with_object({}) do |(header_col, data_col), hash|
+ header_text = read_cell(sheet, header_row, header_col).to_s
year = extract_year_from_header(header_text)
- hash[col] = year
+ hash[data_col] = year
end
all_rows = (header_row + 1).upto(sheet.last_row).filter_map do |row|
@@ -30,7 +31,7 @@ module ReportParsers
line_code = read_cell(sheet, row, columns[:code_column]).to_s.strip.presence
next if ignored_row?(line_name, line_code)
- values = columns[:value_columns].each_with_object({}) do |col, hash|
+ values = columns[:data_value_columns].each_with_object({}) do |col, hash|
value = read_cell(sheet, row, col)
year = value_headers[col]
hash[year.to_s] = value if value.present? && year.present?
@@ -46,7 +47,7 @@ module ReportParsers
end
payloads_by_year = {}
- value_headers.each do |col, year|
+ value_headers.each do |_data_col, year|
next unless year.present?
rows_for_year = all_rows.map do |row|
@@ -114,13 +115,29 @@ module ReportParsers
value_columns = columns.keys.select { |col| col > code_column && columns[col].present? }
end
+ original_cols = value_columns.sort.first(3)
{
name_column: name_column,
code_column: code_column,
- value_columns: value_columns.sort.first(3)
+ value_columns: original_cols,
+ data_value_columns: calibrate_value_columns(sheet, header_row, original_cols, code_column)
}
end
+ def calibrate_value_columns(sheet, header_row, value_columns, code_column)
+ data_row = (header_row + 2).upto(sheet.last_row).find do |row|
+ sheet.cell(row, code_column).to_s.strip.match?(/\A\d{4}\z/)
+ end
+ return value_columns unless data_row
+
+ value_columns.map do |col|
+ next col unless sheet.cell(data_row, col).nil? || sheet.cell(data_row, col).to_s.strip.empty?
+
+ shifted = col + 1
+ shifted <= sheet.last_column && !sheet.cell(data_row, shifted).to_s.strip.empty? ? shifted : col
+ end
+ end
+
def extract_year(sheet)
period_text = sheet.cell(4, 1).to_s
year = period_text[/\d{4}/].to_i
diff --git a/app/services/report_parsers/form12_parser.rb b/app/services/report_parsers/form12_parser.rb
index 12df2d8..80ac29b 100644
--- a/app/services/report_parsers/form12_parser.rb
+++ b/app/services/report_parsers/form12_parser.rb
@@ -83,7 +83,6 @@ module ReportParsers
has_code = normalized_cells.any? { |text| text == 'код' || text.start_with?('код ') || text.start_with?('код:') }
return row if has_name && has_code
end
-
1
end
diff --git a/app/services/report_parsers/form13_parser.rb b/app/services/report_parsers/form13_parser.rb
new file mode 100644
index 0000000..0480126
--- /dev/null
+++ b/app/services/report_parsers/form13_parser.rb
@@ -0,0 +1,115 @@
+module ReportParsers
+ class Form13Parser < BaseParser
+ SHEET_NAME = 'Отчет об изменениях капитала'
+ FORM_CODE = '1.3'
+
+ def call
+ return {} unless sheet_exists?(SHEET_NAME)
+
+ sheet = spreadsheet.sheet(SHEET_NAME)
+ build_payloads(sheet)
+ end
+
+ private
+
+ def build_payloads(sheet)
+ header_row = find_section1_header_row(sheet)
+ return {} unless header_row
+
+ report_year = extract_report_year(sheet)
+ return {} unless report_year
+
+ name_col = find_name_column(sheet, header_row)
+ code_col = find_code_column(sheet, header_row)
+ total_col = find_total_column(sheet, header_row)
+ return {} unless code_col && total_col
+
+ end_row = section1_end_row(sheet, header_row)
+ payloads_by_year = {}
+
+ (header_row + 2).upto(end_row) do |row|
+ line_code = read_cell(sheet, row, code_col).to_s.strip.presence
+ next unless line_code&.match?(/\A3[123]\d{2}\z/)
+
+ row_year = year_from_code(line_code, report_year)
+ next unless row_year
+
+ name_cell = read_cell(sheet, row, name_col).to_s.strip
+ name_cell = read_cell(sheet, row, 1).to_s.strip if name_cell.blank?
+ value = read_cell(sheet, row, total_col)
+
+ payloads_by_year[row_year] ||= { form_code: FORM_CODE, sheet_name: SHEET_NAME, rows: [] }
+ payloads_by_year[row_year][:rows] << {
+ line_name: normalize_line_name(name_cell),
+ line_code: line_code,
+ value: value
+ }
+ end
+
+ payloads_by_year
+ end
+
+ def year_from_code(code, report_year)
+ case code[1]
+ when '1' then report_year - 2
+ when '2' then report_year - 1
+ when '3' then report_year
+ end
+ end
+
+ def extract_report_year(sheet)
+ 1.upto([sheet.last_row, 10].min) do |row|
+ 1.upto(sheet.last_column) do |col|
+ m = sheet.cell(row, col).to_s.match(/\bза\s+(20\d{2})\s*г/i)
+ return m[1].to_i if m
+ end
+ end
+ year = yearly_file&.year.to_i
+ year.positive? ? year : nil
+ end
+
+ def find_section1_header_row(sheet)
+ 1.upto(sheet.last_row) do |row|
+ cells = 1.upto(sheet.last_column).map { |col| normalize_label(sheet.cell(row, col)) }
+ next unless cells.any? { |c| c.include?('наименование показателя') }
+ next unless cells.any? { |c| c.include?('код строки') || c == 'код' }
+
+ return row
+ end
+ nil
+ end
+
+ def find_name_column(sheet, header_row)
+ 1.upto(sheet.last_column) do |col|
+ return col if normalize_label(sheet.cell(header_row, col)).include?('наименование показателя')
+ end
+ 1
+ end
+
+ def find_code_column(sheet, header_row)
+ 1.upto(sheet.last_column) do |col|
+ text = normalize_label(sheet.cell(header_row, col))
+ return col if text.include?('код строки') || text == 'код'
+ end
+ nil
+ end
+
+ def find_total_column(sheet, header_row)
+ result = nil
+ [header_row, header_row + 1].each do |row|
+ 1.upto(sheet.last_column) do |col|
+ result = col if normalize_label(sheet.cell(row, col)) == 'итого'
+ end
+ return result if result
+ end
+ nil
+ end
+
+ def section1_end_row(sheet, from_row)
+ (from_row + 1).upto(sheet.last_row) do |row|
+ return row - 1 if normalize_label(sheet.cell(row, 1)).match?(/\A2\.\s*корректировки/i)
+ end
+ sheet.last_row
+ end
+ end
+end
diff --git a/app/sidekiq/file_processor_job.rb b/app/sidekiq/file_processor_job.rb
index 7080ff5..59b04aa 100644
--- a/app/sidekiq/file_processor_job.rb
+++ b/app/sidekiq/file_processor_job.rb
@@ -6,11 +6,12 @@ class FileProcessorJob
include Sidekiq::Job
def perform(inn, report_id)
+ raise ArgumentError, "Некорректный ИНН: #{inn}" unless inn.to_s.match?(/\A\d{10}(\d{2})?\z/)
+
report = Report.find(report_id)
Rails.logger.info "=== Начало загрузки для ИНН: #{inn} ==="
- # Временная папка
- temp_dir = Rails.root.join('tmp', 'reports', inn)
+ temp_dir = Rails.root.join('tmp', 'reports', inn.to_s.gsub(/[^0-9]/, ''))
FileUtils.mkdir_p(temp_dir)
download_inn(inn, temp_dir.to_s)
@@ -26,7 +27,9 @@ class FileProcessorJob
)
# Создаём YearlyFile и прикрепляем xlsx
- yearly_file = organization.yearly_files.build(year: 0)
+ year_from_filename = File.basename(file_path, '.*').split('_')[2].to_i
+ year_from_filename = 0 unless (2000..2099).cover?(year_from_filename)
+ yearly_file = organization.yearly_files.build(year: year_from_filename)
yearly_file.file.attach(
io: File.open(file_path),
filename: File.basename(file_path)
@@ -57,18 +60,28 @@ class FileProcessorJob
'Accept-Language' => 'en-US,en;q=0.9',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}
- content = HTTParty.get(url, headers: headers).body
- json_need ? JSON.parse(content) : content
+ response = HTTParty.get(url, headers: headers, timeout: 30)
+ raise "HTTP #{response.code} при запросе #{url}" unless response.success?
+
+ json_need ? JSON.parse(response.body) : response.body
end
def download_inn(inn, output)
url = "https://bo.nalog.gov.ru/advanced-search/organizations/search?query=#{inn}&page=0&size=20"
content = get_content(url, true)
- id = content['content'][0]['id']
+ org_entry = content.dig('content', 0) or raise "Организация с ИНН #{inn} не найдена"
+ id = org_entry['id']
url = "https://bo.nalog.gov.ru/nbo/organizations/#{id}/bfo/"
content = get_content(url, true)
- periods_and_details_ids = content.map { |item| [item['period'], item['typeCorrections'][0]['correction']['id']] }
+ raise "Нет отчётности для организации #{id}" if content.blank?
+
+ periods_and_details_ids = content.filter_map do |item|
+ details_id = item.dig('typeCorrections', 0, 'correction', 'id')
+ next if details_id.nil?
+
+ [item['period'], details_id]
+ end
periods_and_details_ids.each do |period, details_id|
url = "https://bo.nalog.gov.ru/download/bfo/#{id}?auditReport=false&balance=true"\
diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim
index 03be6ca..7fedada 100644
--- a/app/views/layouts/application.html.slim
+++ b/app/views/layouts/application.html.slim
@@ -7,9 +7,7 @@ html
meta name="turbo-refresh-method" content="morph"
= csrf_meta_tags
= csp_meta_tag
- / = favicon_link_tag 'favicon.ico'
- / = favicon_link_tag 'favicon-32x32.png', sizes: '32x32'
- / = favicon_link_tag 'apple-icon.png', rel: 'apple-touch-icon'
+ = favicon_link_tag 'favicon.svg', type: 'image/svg+xml'
= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload"
= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload"
script[async src="https://ga.jspm.io/npm:es-module-shims@1.8.2/dist/es-module-shims.js" data-turbo-track="reload"]
diff --git a/app/views/organisations/index.html.slim b/app/views/organisations/index.html.slim
index 3d14171..77f515c 100644
--- a/app/views/organisations/index.html.slim
+++ b/app/views/organisations/index.html.slim
@@ -17,3 +17,4 @@
.information-button
a.link[href="/organisations/#{organisation}"]
= "Подробнее"
+ == pagy_nav(@pagy)
diff --git a/app/views/organisations/show.html.slim b/app/views/organisations/show.html.slim
index 7a0fd0c..d7bf58d 100644
--- a/app/views/organisations/show.html.slim
+++ b/app/views/organisations/show.html.slim
@@ -5,6 +5,8 @@
- unless @info_about_organisation.any?
= 'У данной организации пока не загружена отчетность'
- else
+ - min_max_data = prepare_data(@current_doc_info1)
+ - comparsion_data = prepare_data(@current_doc_info1, type_data: 'comparsion')
.informations-data
h2
= "Сведения об организации"
@@ -12,6 +14,17 @@
- @info_about_organisation["Сведения об организации"].each do |key, value|
.information-elem
= "#{key}: #{value}"
+ .organisation-actions
+ = link_to "/organisations/#{@org.id}/reload",
+ class: 'link-action',
+ data: { turbo_method: :post, turbo_confirm: 'Перекачать данные с nalog.gov.ru?' } do
+ i.bi.bi-arrow-clockwise
+ | Обновить данные
+ = link_to "/organisations/#{@org.id}",
+ class: 'link-action link-action--danger',
+ data: { turbo_method: :delete, turbo_confirm: 'Удалить организацию и все её данные? Это действие необратимо.' } do
+ i.bi.bi-trash
+ | Удалить
= render 'organisations/templates/type_doc', target: "type_doc_#{@type_doc}", org_id: "#{@org.id}"
h2
= "#{@doc_name}"
@@ -27,10 +40,20 @@
tr
td = key[0]
td = key[1]
- - @current_doc_info1[key].each do |value|
- td = value
-
+ -if @report_codes.has_key?(key[1])
+ td.description = @report_codes[key[1]]
+ - @current_doc_info1[key].each_with_index do |value, index|
+ - key_group = key[1].nil? ? nil : group_key(key[1])
+ - if min_max_data.has_key?(key_group) && keep_code?(key[1])
+ - min_value = min_max_data[key_group][:min]
+ - max_value = min_max_data[key_group][:max]
+ - p50 = min_max_data[key_group][:p50]
+ - color = get_color_for_cell(value, min: min_value, median: p50, max: max_value)
+ td style="background-color: #{color}" = value
+ - elsif comparsion_data.has_key?(key[1]) && keep_code?(key[1], type_data: 'comparsion')
+ - condition = comparsion_data[key[1]][index]
+ = get_arrow_for_cell(value, condition)
+ - else
+ td = value
- else
- h3
- = "Нет данных"
-
\ No newline at end of file
+ h3 = "Нет данных"
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 3793173..9b6d1fa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,6 +5,8 @@ Rails.application.routes.draw do
get '/auth/login', to: 'auth#login', as: :login
get '/organisations/index'
get '/organisations/:id', to: 'organisations#show'
+ delete '/organisations/:id', to: 'organisations#destroy'
+ post '/organisations/:id/reload', to: 'organisations#reload'
get '/auditors/index'
get '/reports/create'
get '/reports', to: 'reports#index'
diff --git a/db/migrate/20260414100500_seed_report_codes.rb b/db/migrate/20260414100500_seed_report_codes.rb
index d762fb1..a4b82da 100644
--- a/db/migrate/20260414100500_seed_report_codes.rb
+++ b/db/migrate/20260414100500_seed_report_codes.rb
@@ -1,68 +1,70 @@
class SeedReportCodes < ActiveRecord::Migration[7.1]
def up
rows = [
- { code: "1100", name: "Итого внеоборотных активов", description: "Сумма строк 1110, 1120, 1130, 1140, 1150" },
{ code: "1105", name: "Гудвил", description: "-" },
- { code: "1110", name: "Нематериальные активы", description: "-" },
- { code: "1130", name: "Нематериальные поисковые активы", description: "-" },
- { code: "1140", name: "Материальные поисковые активы", description: "-" },
- { code: "1150", name: "Основные средства", description: "-" },
- { code: "1160", name: "Инвестиционная недвижимость", description: "-" },
- { code: "1170", name: "Финансовые вложения", description: "-" },
- { code: "1180", name: "Отложенные налоговые активы", description: "-" },
- { code: "1190", name: "Прочие внеоборотные активы", description: "-" },
- { code: "1200", name: "Итого оборотных активов", description: "-" },
- { code: "1210", name: "Запасы", description: "-" },
+ { code: "1110", name: "Нематериальные активы", description: "Ценности, которые не имеют вещественной формы (товарные знаки, патенты, программы, авторские права)" },
+ { code: "1120", name: "Результаты исследований и разработок", description: "Затраты на незавершенные исследования и разработки" },
+ { code: "1130", name: "Нематериальные поисковые активы", description: "Затраты на поиск полезных ископаемых в виде исследований, лицензий, геологических изысканий" },
+ { code: "1140", name: "Материальные поисковые активы", description: "Оборудование и сооружения, используемые для поиска и разведки месторождений" },
+ { code: "1150", name: "Основные средства", description: "Имущество, используемое длительно в ходе обычной деятельности (недвижимость, земля, оборудование, транспорт)" },
+ { code: "1160", name: "Инвестиционная недвижимость", description: "Долгосрочное имущество, переданное в аренду, лизинг" },
+ { code: "1170", name: "Финансовые вложения", description: "Долгосрочные доходные инвестиции (ценные бумаги, вклады, выданные займы)" },
+ { code: "1180", name: "Отложенные налоговые активы", description: "Сумма налога на прибыль, которую компания сможет уменьшить в будущем" },
+ { code: "1190", name: "Прочие внеоборотные активы", description: "Долгосрочное имущество, не включенное в другие строки раздела I" },
+ { code: "1100", name: "Итого внеоборотных активов", description: "Сумма всего долгосрочного имущества (> 12 мес.)" },
+ { code: "1210", name: "Запасы", description: "Материальные ресурсы, которые используются в ходе текущей деятельности (сырьё, материалы, товары, готовая продукция)" },
{ code: "1215", name: "Долгосрочные активы к продаже", description: "-" },
- { code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям", description: "-" },
- { code: "1230", name: "Дебиторская задолженность", description: "-" },
- { code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)", description: "-" },
- { code: "1250", name: "Денежные средства и денежные эквиваленты", description: "-" },
- { code: "1260", name: "Прочие оборотные активы", description: "-" },
- { code: "1600", name: "БАЛАНС (актив)", description: "-" },
- { code: "1300", name: "Итого капитал/Итого целевое финансирование (в некоммерческой организации)", description: "-" },
- { code: "1310", name: "Уставный капитал/Паевой фонд (в некоммерческой организации)", description: "-" },
- { code: "1320", name: "Собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций/Целевой капитал (в некоммерческой организации)", description: "-" },
- { code: "1330", name: "Целевые средства (в некоммерческой организации)", description: "-" },
+ { code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям", description: "Сумма входящего НДС, не принятая на отчетную дату к вычету" },
+ { code: "1230", name: "Дебиторская задолженность", description: "Неисполненные обязательства контрагентов перед организацией" },
+ { code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)", description: "Краткосрочные доходные инвестиции (ценные бумаги, вклады)" },
+ { code: "1250", name: "Денежные средства и денежные эквиваленты", description: "Деньги и вклады до востребования" },
+ { code: "1260", name: "Прочие оборотные активы", description: "Краткосрочное имущество, не включенное в другие строки раздела I" },
+ { code: "1200", name: "Итого оборотных активов", description: "Сумма всего краткосрочного имущества (< 12 мес.)" },
+ { code: "1600", name: "БАЛАНС (актив)", description: "Общая стоимость имущества организации" },
+ { code: "1310", name: "Уставный капитал/Паевой фонд (в некоммерческой организации)", description: "Зафиксированная в учредительных документах сумма, образованная из вкладов учредителей (участников)" },
+ { code: "1320", name: "Собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций/Целевой капитал (в некоммерческой организации)", description: "Стоимость выкупленных акций для перепродажи или аннулирования" },
+ { code: "1330", name: "Целевые средства (в некоммерческой организации)", description: "Дооценка основных средств, нематериальных активов до справедливой стоимости" },
{ code: "1340", name: "Накопленная дооценка внеоборотных активов", description: "-" },
- { code: "1350", name: "Добавочный капитал (без накопленной дооценки)", description: "-" },
- { code: "1360", name: "Резервный капитал/Фонд недвижимого и особо ценного движимого имущества (в некоммерческой организации)", description: "-" },
- { code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)/Резервный и иные целевые фонды (в некоммерческой организации)", description: "-" },
- { code: "1400", name: "Итого долгосрочных обязательств", description: "-" },
- { code: "1410", name: "Долгосрочные заемные средства", description: "-" },
- { code: "1420", name: "Отложенные налоговые обязательства", description: "-" },
- { code: "1430", name: "Долгосрочные оценочные обязательства", description: "-" },
- { code: "1450", name: "Прочие долгосрочные обязательства", description: "-" },
- { code: "1500", name: "Итого краткосрочных обязательств", description: "-" },
- { code: "1510", name: "Краткосрочные заемные средства", description: "-" },
- { code: "1520", name: "Краткосрочная кредиторская задолженность", description: "-" },
+ { code: "1350", name: "Добавочный капитал (без накопленной дооценки)", description: "Дополнительные взносы учредителей (участников), эмиссионный доход" },
+ { code: "1360", name: "Резервный капитал/Фонд недвижимого и особо ценного движимого имущества (в некоммерческой организации)", description: "Часть чистой прибыли, используемая для покрытия возможных убытков" },
+ { code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)/Резервный и иные целевые фонды (в некоммерческой организации)", description: "Чистая прибыль, которая не была распределена между участниками/акционерами" },
+ { code: "1300", name: "Итого капитал/Итого целевое финансирование (в некоммерческой организации)", description: "Сумма собственного капитала" },
+ { code: "1410", name: "Долгосрочные заемные средства", description: "Кредиты и займы, привлеченные организацией на срок > 12 мес." },
+ { code: "1420", name: "Отложенные налоговые обязательства", description: "Сумма налога на прибыль, которую компания заплатит в будущем" },
+ { code: "1430", name: "Долгосрочные оценочные обязательства", description: "Обязательства с неопределенной суммой или сроком исполнения (> 12 мес.)" },
+ { code: "1450", name: "Прочие долгосрочные обязательства", description: "Долгосрочные обязательства, не включенные в другие строки раздела IV" },
+ { code: "1400", name: "Итого долгосрочных обязательств", description: "Сумма всех долгосрочных обязательств" },
+ { code: "1510", name: "Краткосрочные заемные средства", description: "Кредиты и займы, привлеченные организацией на срок < 12 мес." },
+ { code: "1520", name: "Краткосрочная кредиторская задолженность", description: "Неисполненные обязательства организации перед контрагентами" },
{ code: "1530", name: "Доходы будущих периодов", description: "-" },
- { code: "1540", name: "Краткосрочные оценочные обязательства", description: "-" },
- { code: "1550", name: "Прочие краткосрочные обязательства", description: "-" },
- { code: "2110", name: "Выручка", description: "-" },
- { code: "2120", name: "Себестоимость продаж", description: "-" },
- { code: "2100", name: "Валовая прибыль (убыток)", description: "-" },
- { code: "2210", name: "Коммерческие расходы", description: "-" },
- { code: "2220", name: "Управленческие расходы", description: "-" },
- { code: "2200", name: "Прибыль (убыток) от продаж", description: "-" },
- { code: "2310", name: "Доходы от участия в других организациях", description: "-" },
- { code: "2320", name: "Проценты к получению", description: "-" },
- { code: "2330", name: "Проценты к уплате", description: "-" },
- { code: "2340", name: "Прочие доходы", description: "-" },
- { code: "2350", name: "Прочие расходы", description: "-" },
- { code: "2300", name: "Прибыль (убыток) от продолжающейся деятельности до налогообложения", description: "-" },
- { code: "2410", name: "Налог на прибыль организаций", description: "-" },
- { code: "2411", name: "текущий налог на прибыль организаций", description: "в том числе" },
- { code: "2412", name: "отложенный налог на прибыль организаций", description: "в том числе" },
+ { code: "1540", name: "Краткосрочные оценочные обязательства", description: "Обязательства с неопределенной суммой или сроком исполнения (< 12 мес.)" },
+ { code: "1550", name: "Прочие краткосрочные обязательства", description: "Краткосрочные обязательства, не включенные в другие строки раздела V" },
+ { code: "1500", name: "Итого краткосрочных обязательств", description: "Сумма всех краткосрочных обязательств" },
+ { code: "1700", name: "БАЛАНС (пассив)", description: "Общая стоимость источников формирования имущества организации" },
+ { code: "2110", name: "Выручка", description: "Доходы от продажи товаров (оказания услуг, выполнения работ)" },
+ { code: "2120", name: "Себестоимость продаж", description: "Прямые расходы, направленные на получение выручки" },
+ { code: "2100", name: "Валовая прибыль (убыток)", description: "Разница между выручкой и себестоимостью" },
+ { code: "2210", name: "Коммерческие расходы", description: "Расходы на сбыт, рекламу и маркетинг" },
+ { code: "2220", name: "Управленческие расходы", description: "Расходы на содержание аппарата управления" },
+ { code: "2200", name: "Прибыль (убыток) от продаж", description: "Валовая прибыль за вычетом коммерческих и управленческих расходов" },
+ { code: "2310", name: "Доходы от участия в других организациях", description: "Дивиденды от дочерних компаний" },
+ { code: "2320", name: "Проценты к получению", description: "Процентные доходы от финансовых вложений (акций, вкладов, выданных займов)" },
+ { code: "2330", name: "Проценты к уплате", description: "Процентные расходы по полученным кредитам и займам" },
+ { code: "2340", name: "Прочие доходы", description: "Прочие нерегулярные доходы" },
+ { code: "2350", name: "Прочие расходы", description: "Прочие нерегулярные расходы" },
+ { code: "2300", name: "Прибыль (убыток) от продолжающейся деятельности до налогообложения", description: "Прибыль (убыток) до налогообложения за вычетом доходоы и расходов по прочим видам деятельности" },
+ { code: "2410", name: "Налог на прибыль организаций", description: "Сумма текущего и отложенного налога" },
+ { code: "2411", name: "текущий налог на прибыль организаций", description: "Налог к уплате в текущем периоде" },
+ { code: "2412", name: "отложенный налог на прибыль организаций", description: "Налог к уплате в будущих периодах" },
{ code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности (за вычетом относящегося к ней налога на прибыль организаций)", description: "-" },
- { code: "2460", name: "Прочее", description: "-" },
- { code: "2400", name: "Чистая прибыль (убыток)", description: "-" },
- { code: "2510", name: "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)", description: "-" },
- { code: "2520", name: "Результат от прочих операций, не включаемый в чистую прибыль (убыток)", description: "-" },
- { code: "2530", name: "Налог на прибыль организаций, относящийся к результатам переоценки внеоборотных активов и прочих операций, не включаемых в чистую прибыль (убыток)", description: "-" },
- { code: "2500", name: "Совокупный финансовый результат", description: "-" },
- { code: "2900", name: "Базовая прибыль (убыток) на акцию", description: "-" },
- { code: "2910", name: "Разводненная прибыль (убыток) на акцию", description: "-" },
+ { code: "2460", name: "Прочее", description: "Корректировчки чистой прибыли" },
+ { code: "2400", name: "Чистая прибыль (убыток)", description: "Финансовый результат, доступный к распределению акционерами (участниками) или накоплению" },
+ { code: "2510", name: "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)", description: "Изменение стоимости основных средств и нематериальных активов" },
+ { code: "2520", name: "Результат от прочих операций, не включаемый в чистую прибыль (убыток)", description: "Курсовые разницы по деятельности за рубежом" },
+ { code: "2530", name: "Налог на прибыль организаций, относящийся к результатам переоценки внеоборотных активов и прочих операций, не включаемых в чистую прибыль (убыток)", description: "Налог с операций вне чистой прибыли" },
+ { code: "2500", name: "Совокупный финансовый результат", description: "Сумма чистой прибыли (убытка) и прочего финансового результата" },
+ { code: "2900", name: "Базовая прибыль (убыток) на акцию", description: "Чистая прибыль, поделенная на одну обыкновенную акцию" },
+ { code: "2910", name: "Разводненная прибыль (убыток) на акцию", description: "Прибыль на акцию в случае конвертации привилегированных акций в обыкновенные" },
{ code: "3100", name: "Величина капитала на 31 декабря года, предшествующего предыдущему", description: "-" },
{ code: "3110", name: "Корректировка в связи с изменением учетной политики", description: "-" },
{ code: "3120", name: "Корректировка в связи с исправлением ошибок", description: "-" },