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: "-" },