Compare commits

48 Commits

Author SHA1 Message Date
Dmitry 77382c918c Обновлениме deploy 2026-05-18 20:24:37 +03:00
Dmitry e64dfb767b Add db:seed to deploy 2026-05-18 17:20:48 +00:00
Dmitry 4483aba8ce Switch Puma to systemd management 2026-05-18 17:04:57 +00:00
Dmitry 17508519f2 Use capistrano-master-key for automatic key upload 2026-05-18 17:02:46 +00:00
Dmitry cbc83c0caf Regenerate credentials 2026-05-18 16:42:43 +00:00
Дмитрий Антипенко 5425a95219 Merge branch 'fix/SU2-33' of DAAntipenko/finreport-analyzer into develop 2026-04-27 18:56:31 +03:00
Dmitry fabc2df38c Добавлено игнорирование файла 2026-04-27 18:55:57 +03:00
ARLakhin e47ec8afcc SU2-33 | Изменено отображение таблицы стилистически 2026-04-27 13:46:02 +03:00
ARLakhin e682897dce SU2-33 | Отображение пояснений 2026-04-27 13:43:54 +03:00
ARLakhin 37e7d25976 SU2-33 | Изменена логика раскраски 2026-04-27 13:02:28 +03:00
ARLakhin 3f889b5b9a SU2-33 | Пояснения для некоторых кодов 2026-04-27 13:01:50 +03:00
ARLakhin 1134732a32 SU2-33 | Показ только непустых сведений об организации 2026-04-21 13:06:37 +03:00
Dmitry aea8b7975e SU2-33 | Парсер формы 1.3, фикс смещения колонок в упрощённой отчётности 2026-04-21 08:33:24 +00:00
Dmitry 136de5f238 SU2-33 | Фикс критических и серьёзных ошибок, удаление/перезагрузка организации, пагинация, favicon 2026-04-21 07:55:50 +00:00
ARLakhin a1cbddf4e2 Убрана сортировка кодов и изменен порядок кодов в БД 2026-04-21 09:22:51 +03:00
ARLakhin e54299c13b Добавлена логика для отображения стрелок и раскраски | Изменена ширина первого столбца таблицы 2026-04-21 00:30:22 +03:00
ARLakhin bc095da738 Добавлены стрелочки и стиль для их отображения 2026-04-21 00:29:20 +03:00
ARLakhin c750e470d6 SU2-33 | Очистка данных от пустых строк и столбцов 2026-04-20 23:40:48 +03:00
Dmitry 44cd3993ac SU2-33 | Фикс перезаписи парсером значений в БД 2026-04-20 18:45:52 +00:00
Дмитрий Антипенко e9660f016f Merge branch 'fix/SU2-32' of DAAntipenko/finreport-analyzer into develop 2026-04-19 16:17:12 +03:00
Dmitry 6fae7b50eb SU2-32 | фикс для дублей 2026-04-19 13:16:50 +00:00
Дмитрий Антипенко 562fad5b22 Merge branch 'fastfix' of DAAntipenko/finreport-analyzer into develop 2026-04-18 22:06:26 +03:00
Dmitry db08f63ac2 Быстрый фикс | Парсинг значений (-)2 в формате прочерка 2026-04-18 19:04:51 +00:00
Дмитрий Антипенко 3b35a63346 Merge branch 'features/SU2-30' of DAAntipenko/finreport-analyzer into develop 2026-04-18 20:05:55 +03:00
Dmitry a03839a834 SU2-30 | добавлена фикс таблица для кодов, убрана таблица DocumentTypes, изменена структура jsonb 2026-04-18 17:05:19 +00:00
Дмитрий Антипенко c74ffc2aea Merge branch 'SU2-26' of DAAntipenko/finreport-analyzer into develop 2026-04-14 10:40:11 +03:00
Dmitry 4484126e8f Убрать лишние символы при отображении отчетностей 2026-04-08 15:35:21 +00:00
Дмитрий Антипенко a246648340 Merge branch 'feature/SU2-20' of DAAntipenko/finreport-analyzer into develop 2026-04-02 21:34:57 +03:00
Дмитрий Антипенко 1be0c00ba4 Merge branch 'fix/SU2-26' of DAAntipenko/finreport-analyzer into develop 2026-04-02 21:33:10 +03:00
Dmitry 92f0cdd272 SU2-26 | Убрал лишние записи в jsonb 2026-04-02 18:31:46 +00:00
ARLakhin 7c390e9a69 Переработка контроллера Organization и страницы Show для отображения 2х форм отчетности из БД 2026-04-01 15:06:00 +03:00
Дмитрий Антипенко 644d7616ae Merge branch 'hotfix/SU2-25' of DAAntipenko/finreport-analyzer into develop 2026-03-26 09:18:40 +03:00
Dmitry 9a74c2c598 SU2-25 | Добавлено отображение тестовых ИНН на странице загрузки отчетов 2026-03-26 09:18:11 +03:00
Дмитрий Антипенко b5ae88f2b8 Merge branch 'features/layout' of DAAntipenko/finreport-analyzer into develop 2026-03-26 09:03:49 +03:00
ARLakhin f944c248ea Добавлены 4 кнопки для каждой формы 2026-03-26 02:06:08 +03:00
ARLakhin b06903f448 Merge branch 'develop' into features/layout 2026-03-26 00:49:13 +03:00
ARLakhin 853b82660f Добавление кнопки выхода, Изменение главной страницы на home, Выделение отдельного контроллера под организацию 2026-03-26 00:45:44 +03:00
ARLakhin df4c307e24 Созданы новые css-файлы, изменен main.css и application_settings 2026-03-26 00:18:00 +03:00
Дмитрий Антипенко 85fbac1cfd Merge branch 'feature/SU2-19' of DAAntipenko/finreport-analyzer into develop 2026-03-25 19:20:28 +03:00
Dmitry 7ee21d5e5d Добавлено парсирование формы 14 и сохранение финансовых отчетов в базу данных; добавлена миграция для начальных данных типов документов 2026-03-25 09:48:22 +03:00
Dmitry 207e4b45a7 SU2-19 | Парсинг двух форм, пока без понимания того, что делать с расхожениями в данных (обе формы в одном файле) 2026-03-21 15:26:37 +03:00
Дмитрий Антипенко dcba938feb Merge branch 'features/layout' of DAAntipenko/finreport-analyzer into develop 2026-03-21 12:38:03 +03:00
ARLakhin 70f051da92 Визуальные изменения основной странички 2026-03-21 12:35:57 +03:00
Дмитрий Антипенко 4e521a5422 Merge branch 'features/models' of DAAntipenko/finreport-analyzer into features/layout 2026-03-12 13:28:32 +03:00
Dmitry f973ec78df Контроллер настроен на парсер, готово к PR 2026-03-12 13:26:48 +03:00
Dmitry c016d6a319 Добавление миграций БД и моделей 2026-03-12 11:30:22 +03:00
ARLakhin e55ecb7d29 Auditor/Organisation layouts 2026-03-12 10:14:15 +03:00
ARLakhin 0dc0f10da7 Test layout 2026-03-10 23:44:59 +03:00
67 changed files with 2415 additions and 149 deletions
+1
View File
@@ -35,3 +35,4 @@
/node_modules
.idea/
TZ.xlsx
+8 -31
View File
@@ -1,38 +1,15 @@
# Load DSL and set up stages
require "capistrano/setup"
# Include default deployment tasks
require "capistrano/deploy"
# Load the SCM plugin appropriate to your project:
#
# require "capistrano/scm/hg"
# install_plugin Capistrano::SCM::Hg
# or
# require "capistrano/scm/svn"
# install_plugin Capistrano::SCM::Svn
# or
require "capistrano/scm/git"
install_plugin Capistrano::SCM::Git
# Include tasks from other gems included in your Gemfile
#
# For documentation on these, see for example:
#
# https://github.com/capistrano/rvm
# https://github.com/capistrano/rbenv
# https://github.com/capistrano/chruby
# https://github.com/capistrano/bundler
# https://github.com/capistrano/rails
# https://github.com/capistrano/passenger
#
# require "capistrano/rvm"
# require "capistrano/rbenv"
# require "capistrano/chruby"
# require "capistrano/bundler"
# require "capistrano/rails/assets"
# require "capistrano/rails/migrations"
# require "capistrano/passenger"
require "capistrano/master_key"
require "capistrano/rvm"
require "capistrano/bundler"
require "capistrano/rails/assets"
require "capistrano/rails/migrations"
require "capistrano/puma"
install_plugin Capistrano::Puma
install_plugin Capistrano::Puma::Systemd
# Load custom tasks from `lib/capistrano/tasks` if you have any defined
Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r }
+1
View File
@@ -9,6 +9,7 @@ gem "rails", "~> 7.1.3", ">= 7.1.3.2"
gem "sidekiq" # background jobs processing
gem 'httparty' # for http requests
gem 'rubyzip' # for zip file processing
gem 'roo', '~> 2.10' # for xlsx file parsing
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem "sprockets-rails"
+4
View File
@@ -335,6 +335,9 @@ GEM
request_store (1.7.0)
rack (>= 1.4)
rexml (3.3.9)
roo (2.10.1)
nokogiri (~> 1)
rubyzip (>= 1.3.0, < 3.0.0)
rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
@@ -496,6 +499,7 @@ DEPENDENCIES
rack-cas!
rails (~> 7.1.3, >= 7.1.3.2)
redis (>= 4.0.1)
roo (~> 2.10)
rspec-rails (~> 6.1.0)
rubyzip
selenium-webdriver
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path
d="M8 15 L1 8 H5 V1 H11 V8 H15 Z"
fill="#D9534F"
stroke="#C23026"
stroke-width="1"
/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path
d="M8 1 L15 8 H11 V15 H5 V8 H1 Z"
fill="#6A9E8A"
stroke="#497C64"
stroke-width="1"
/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#1a1a1a"/>
<rect x="5" y="22" width="5" height="6" rx="1" fill="white"/>
<rect x="13" y="16" width="5" height="12" rx="1" fill="white"/>
<rect x="21" y="10" width="5" height="18" rx="1" fill="white"/>
<polyline points="7.5,18 15.5,12 23.5,6" stroke="#4ade80" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7.5" cy="18" r="1.5" fill="#4ade80"/>
<circle cx="15.5" cy="12" r="1.5" fill="#4ade80"/>
<circle cx="23.5" cy="6" r="1.5" fill="#4ade80"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

+5
View File
@@ -19,3 +19,8 @@
@use "toastr";
@use "base";
@use "application_settings";
@use "main";
@use "header";
@use "nav";
@use "organisation";
@@ -0,0 +1,42 @@
:root{
--bg-color: #F3F3F5;
--navbar-border: #d3d3d3;
--blue-color: #0078F6;
--blue-light-color: #e5f1fe;
--text-callout-color: #B6B6B6;
--yellow-color: #F9D256;
--white-color: #fff;
--red-color: #EF3E3B;
--footer-text-color: #757575;
--purple-color: #4C13A2;
--green-color: #4CBFC0;
--orange-color: #FE9F40;
--grey-color: #C9CBCE;
--grey-light-color:#F6F6F6;
--grey-dark-color:#a9a9a9;
--border-dropdown-color: #e8e8e8;
--text-dropdown-color: #0F0F11;
--main-header-text: #7C7C7C;
//TEXT weight
--bold: 700;
--semi-bold: 600;
--medium: 500;
--regular: 400;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
width: 90vw;
margin: 0 auto;
}
.welcome__container__laptop {
margin-top: 15%;
text-align: center;
font-size: 36px;
font-family: "Ubuntu sans";
font-weight: 600;
}
+13
View File
@@ -0,0 +1,13 @@
.main-header {
width: 100%;
font-size: 2.5rem;
font-weight: var(--bold);
text-align: center;
position: relative;
}
.exit {
position: absolute;
top: 0;
right: 5px;
}
+162
View File
@@ -0,0 +1,162 @@
.add-new {
text-align: center;
width: 100%;
margin-block: 15px;
}
a.link {
border: 1px solid black;
border-radius: 20px 20px;
padding: 10px;
font-size: 1.5rem;
}
a.link:hover {
background-color: black;
color: var(--white-color)
}
.header{
width: 100%;
text-align: center;
}
.header-title {
font-size: 2.5rem;
font-weight: var(--bold);
}
.main-container {
height: 100vh;
padding-bottom: 15px;
}
.informations-data {
display: flex;
flex-direction: column;
width: 95%;
min-width: 300px;
max-width: 1500px;
margin: 0 auto;
padding-bottom: inherit;
}
.informations-data h2 {
font-weight: 700;
font-size: 2.5em;
}
.information-data {
border: 2px solid black;
display: flex;
flex-direction: column;
padding: 5px;
margin: 20px;
}
.information-elem {
font-weight: var(--semi-bold);
font-size: 1.5rem;
padding: 2.5px;
}
.add-new-organisation {
border: 1px solid #ccc;
padding: 10px;
margin: 10px 0;
}
.information-button {
align-self: center;
margin-block: 8px;
}
.information-table table {
border-collapse: collapse;
text-align: center;
}
.information-table td, th {
padding: 5px;
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;
}
.type__doc__list__type__container {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
place-items: center;
gap: 15px;
a.link {
font-size: 1.35rem;
}
}
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);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
.nav-container {
width: 25%;
margin: 0 auto;
display: flex;
justify-content: space-around;
}
+19
View File
@@ -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');
}
+13
View File
@@ -0,0 +1,13 @@
class AuditorsController < ApplicationController
def index
auditors = Auditor.all
@info_about_auditors = auditors.each_with_object({}) do |auditor, hash|
hash[auditor.id] = {
"Наименование аудиторской организации/ФИО индивидуального аудитора" => auditor.name,
"ИНН" => auditor.inn,
"ОГРН/ОРГНИП" => auditor.ogrn
}
end
end
end
+8 -7
View File
@@ -2,11 +2,13 @@ module Authorization
extend ActiveSupport::Concern
included do
before_action :set_paper_trail_whodunnit
before_action :load_current_user # load user from database
before_action :set_paper_trail_whodunnit
before_action :check_authentication # check if user authenticated
before_action :check_current_user # check if user authorized
helper_method :logged_in?, :current_user
rescue_from ActionController::UnknownFormat do
request.format = :html
render_error
@@ -16,24 +18,23 @@ module Authorization
session['zombie'] || cas_login
end
def logged_in?
current_login.present?
end
private
def cas_login
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
+190
View File
@@ -0,0 +1,190 @@
class OrganisationsController < ApplicationController
before_action :set_organisation, :set_report_codes, only: [:show, :destroy, :reload]
DOC_TYPES = {
0 => { title: 'Бухгалтерский баланс', form_code: '1.1' },
1 => { title: 'Отчёт о финансовых результатах', form_code: '1.2' },
2 => { title: 'Отчёт об изменении капитала', form_code: '1.3' },
3 => { title: 'Отчёт о движении денежных средств', form_code: '1.4' }
}.freeze
def index
@pagy, organizations = pagy(Organization.order(:created_at))
@info_about_organisations = organizations.each_with_object({}) do |org, hash|
hash[org.id] = {
"ИНН" => org.inn,
"Полное наименование юридического лица" => org.full_name,
# "КПП" => org.kpp,
# "Код по ОКПО" => org.okpo,
# "Форма собственности (по ОКФС)" => org.okfs,
# "Организационно-правовая форма (по ОКОПФ)" => org.okopf,
# "Вид экономической деятельности по ОКВЭД 2" => org.okved2,
# "Местонахождение (адрес)" => org.address
}
end
end
def show
@type_doc = params[:type_doc].present? ? params[:type_doc].to_i : 0
statements_by_year_and_form = build_statements_by_year_and_form
years = statements_by_year_and_form.keys.sort.reverse
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_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 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|
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
payload
end
def build_grouped_rows(statements_by_year_and_form, years)
data = DOC_TYPES.each_with_object({}) do |(_, doc_type), hash|
hash[doc_type[:title]] = {}
end
years.each do |year|
DOC_TYPES.each_value do |doc_type|
statement = statements_by_year_and_form.dig(year, doc_type[:form_code])
rows = statement ? extract_rows(statement, doc_type[:form_code]) : []
rows.each do |row|
line_name = row['line_name'] || row[:line_name]
line_code = row['line_code'] || row[:line_code]
next if line_name.blank? && line_code.blank?
key = [line_name, line_code]
data[doc_type[:title]][key] ||= []
data[doc_type[:title]][key] << (row['value'] || row[:value] || '-')
end
end
end
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] = {} }
@org.financial_statements.includes(:yearly_file).each do |statement|
year = statement.sheet_number.to_i
next unless year.positive?
next unless unified_statement?(statement)
DOC_TYPES.each_value do |doc_type|
form_code = doc_type[:form_code]
next if extract_rows(statement, form_code).blank?
current = grouped[year][form_code]
grouped[year][form_code] = statement if better_statement?(statement, current)
end
end
grouped
end
def better_statement?(candidate, current)
return true if current.nil?
candidate_source_year = candidate.yearly_file&.year.to_i
current_source_year = current.yearly_file&.year.to_i
return true if candidate_source_year > current_source_year
return false if candidate_source_year < current_source_year
candidate.updated_at > current.updated_at
end
def unified_statement?(statement)
forms = statement.data.is_a?(Hash) ? (statement.data['forms'] || statement.data[:forms]) : nil
forms.is_a?(Hash)
end
def extract_rows(statement, form_code)
forms = statement.data['forms'] || statement.data[:forms] || {}
form_payload = forms[form_code] || forms[form_code.to_sym] || {}
rows = form_payload['rows'] || form_payload[:rows] || []
return rows if rows.is_a?(Array)
[]
end
def column_header_for(form_code, year)
return "На 31 декабря #{year} г." if form_code == '1.1'
"За #{year} г."
end
end
+6 -2
View File
@@ -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: 'Отчёт загружается в фоне'
+9 -2
View File
@@ -1,4 +1,11 @@
class WelcomeController < ApplicationController
def home; end
def home
if logged_in?
if @current_user.present?
redirect_to '/organisations/index'
else
render_error
end
end
end
end
+2
View File
@@ -0,0 +1,2 @@
module AuditorsHelper
end
+113
View File
@@ -0,0 +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
+1
View File
@@ -4,4 +4,5 @@ import "controllers"
import "jquery"
import "jquery_ujs"
import "toastr"
import "tabs_select"
window.toastr = toastr
+18
View File
@@ -0,0 +1,18 @@
let tabs_select = () => {
const tabsContainer = document.getElementById("tabs")
if (!tabsContainer) {
return;
}
const buttons = tabsContainer.getElementsByClassName("switch__button")
for (const button of buttons) {
const value = button.getAttribute("data-value");
const id = button.getAttribute("id");
button.classList.toggle("active__button", value === id);
}
}
document.addEventListener('turbo:load', tabs_select)
console.log('Hi')
+3
View File
@@ -0,0 +1,3 @@
class Auditor < ApplicationRecord
has_many :yearly_files, dependent: :nullify
end
+5
View File
@@ -0,0 +1,5 @@
class FinancialStatement < ApplicationRecord
belongs_to :yearly_file
validates :data, presence: true
end
+6
View File
@@ -0,0 +1,6 @@
class Organization < ApplicationRecord
has_many :yearly_files, dependent: :destroy
has_many :financial_statements, through: :yearly_files
validates :inn, presence: true
end
+4
View File
@@ -0,0 +1,4 @@
class ReportCode < ApplicationRecord
validates :code, presence: true, uniqueness: true
validates :name, presence: true
end
+10
View File
@@ -0,0 +1,10 @@
class YearlyFile < ApplicationRecord
belongs_to :organization
belongs_to :auditor, optional: true
has_many :financial_statements, dependent: :destroy
has_one_attached :file
validates :year, presence: true
end
+237
View File
@@ -0,0 +1,237 @@
require 'roo'
class ReportParser
FORM_DEFINITIONS = {
'1.1' => {
parser: ReportParsers::Form11Parser,
sheet_name: 'Бухгалтерский баланс'
},
'1.2' => {
parser: ReportParsers::Form12Parser,
sheet_name: 'Отчет о финансовых результатах'
},
'1.3' => {
parser: ReportParsers::Form13Parser,
sheet_name: 'Отчет об изменениях капитала'
},
'1.4' => {
parser: ReportParsers::Form14Parser,
sheet_name: 'Отчет о движении денежных средств'
}
}.freeze
DEFAULT_MISSING_VALUE = '-'
def initialize(yearly_file)
@yearly_file = yearly_file
end
def call
ActiveRecord::Base.transaction do
ReportParsers::OrganizationInfoParser.new(@yearly_file, spreadsheet: spreadsheet).call
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)
end
end
def parse_forms
FORM_DEFINITIONS.each_with_object({}) do |(form_code, config), hash|
parser_class = config.fetch(:parser)
hash[form_code] = parser_class ? parser_class.new(@yearly_file, spreadsheet: spreadsheet).call : {}
end
end
def save_aggregated_statements(parsed_forms)
codes = report_codes
return if codes.blank?
target_years = extract_target_years(parsed_forms)
return if target_years.blank?
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, previous_data: previous_data)
)
statement.save!
end
cleanup_duplicate_statements(year, statement.id)
end
end
def extract_target_years(parsed_forms)
years = parsed_forms.values.flat_map(&:keys).map(&:to_i).select(&:positive?).uniq
years = [@yearly_file.year.to_i] if years.blank? && @yearly_file.year.to_i.positive?
years.sort.reverse
end
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_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)
hash[form_code] = {
form_code: form_code,
sheet_name: form_data[:sheet_name] || form_data['sheet_name'] || config.fetch(:sheet_name),
rows: form_codes.map do |report_code|
source_row = rows_by_code[report_code.code]
source_value = source_row && (source_row[:value] || source_row['value'])
{
line_name: report_code.name,
line_code: report_code.code,
value: normalized_statement_value(source_value)
}
end
}
end
{
data_year: year,
forms: forms_payload
}
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'])
next if normalized_code.blank?
# Keep first non-blank value for duplicated codes inside one form/year.
existing = hash[normalized_code]
next if existing.present? && (existing[:value] || existing['value']).present?
hash[normalized_code] = row
end
end
def find_or_build_statement(year)
existing = FinancialStatement
.joins(:yearly_file)
.where(sheet_number: year)
.where(yearly_files: { organization_id: @yearly_file.organization_id })
.order('yearly_files.year DESC NULLS LAST, financial_statements.updated_at DESC')
.first
return existing if existing
@yearly_file.financial_statements.new(sheet_number: year)
end
def should_update_statement?(statement)
return true if statement.new_record?
existing_source_year = statement.yearly_file&.year.to_i
current_source_year = @yearly_file.year.to_i
existing_source_year <= current_source_year
end
def normalize_code(value)
value.to_s.scan(/\d+/).join.presence
end
def normalized_statement_value(value)
return DEFAULT_MISSING_VALUE if value.nil?
return value if value.is_a?(Numeric)
text = value.to_s.strip
return DEFAULT_MISSING_VALUE if text.blank?
numeric_text?(text) ? text : DEFAULT_MISSING_VALUE
end
def numeric_text?(text)
normalized = text.tr("\u00A0", ' ').squeeze(' ')
integer_or_decimal = /\A[+-]?\d+(?:[.,]\d+)?\z/
integer_or_decimal_grouped = /\A[+-]?\d{1,3}(?: \d{3})+(?:[.,]\d+)?\z/
parenthesized_integer_or_decimal = /\A\(\d+(?:[.,]\d+)?\)\z/
parenthesized_integer_or_decimal_grouped = /\A\(\d{1,3}(?: \d{3})+(?:[.,]\d+)?\)\z/
normalized.match?(integer_or_decimal) ||
normalized.match?(integer_or_decimal_grouped) ||
normalized.match?(parenthesized_integer_or_decimal) ||
normalized.match?(parenthesized_integer_or_decimal_grouped)
end
def report_codes
@report_codes ||= ReportCode.select(:code, :name).order(:id).to_a
end
def report_codes_for_form(form_code, codes)
codes.select { |report_code| report_code_belongs_to_form?(report_code.code, form_code) }
end
def report_code_belongs_to_form?(code, form_code)
normalized_code = normalize_code(code)
return false if normalized_code.blank?
case form_code
when '1.1' then normalized_code.start_with?('1')
when '1.2' then normalized_code.start_with?('2')
when '1.3' then normalized_code.start_with?('3')
when '1.4' then normalized_code.start_with?('4')
else
true
end
end
def cleanup_duplicate_statements(year, keeper_id)
FinancialStatement
.joins(:yearly_file)
.where(sheet_number: year)
.where(yearly_files: { organization_id: @yearly_file.organization_id })
.where.not(id: keeper_id)
.destroy_all
end
end
+111
View File
@@ -0,0 +1,111 @@
require 'set'
module ReportParsers
class BaseParser
def initialize(yearly_file, spreadsheet:)
@yearly_file = yearly_file
@spreadsheet = spreadsheet
end
private
attr_reader :yearly_file, :spreadsheet
def sheet_exists?(sheet_name)
spreadsheet.sheets.include?(sheet_name)
end
def read_cell(sheet, row, col)
value = sheet.cell(row, col)
# Handle merged cells: if value is nil/blank, try reading from nearby cells
if value.blank? && sheet.respond_to?(:merged_cells)
value = find_merged_cell_value(sheet, row, col)
end
value.is_a?(String) ? value.strip.presence : value
end
def find_merged_cell_value(sheet, row, col)
# Try to find value in merged cell range
# Check cells in a small radius around the target cell
(-1..1).each do |row_offset|
(-1..1).each do |col_offset|
next if row_offset == 0 && col_offset == 0
check_row = row + row_offset
check_col = col + col_offset
next if check_row < 1 || check_col < 1
next if check_row > sheet.last_row || check_col > sheet.last_column
value = sheet.cell(check_row, check_col)
return value if value.present?
end
end
nil
end
def find_value_by_labels(sheet, labels, row_range:, value_offset: 1)
position = find_label_position(sheet, labels, row_range: row_range)
return if position.nil?
row, col = position
max_col = sheet.last_column.to_i
(col + value_offset).upto(max_col) do |search_col|
value = read_cell(sheet, row, search_col)
return value if value.present?
end
nil
end
def find_label_position(sheet, labels, row_range:)
normalized_labels = Array(labels).map { |label| normalize_label(label) }.to_set
max_col = sheet.last_column.to_i
return if max_col <= 1
row_range.each do |row|
1.upto(max_col - 1) do |col|
label_text = normalize_label(sheet.cell(row, col))
next if label_text.blank?
next unless label_matches?(label_text, normalized_labels)
return [row, col]
end
end
nil
end
def label_matches?(label_text, normalized_labels)
normalized_labels.any? do |label|
label_text == label ||
label_text.start_with?("#{label} ") ||
label_text.start_with?("#{label}:")
end
end
def normalize_label(value)
value.to_s
.downcase
.tr("\u00A0", ' ')
.gsub(/\s+/, ' ')
.gsub(/[«»]/, '')
.strip
end
def normalize_line_name(value)
text = value.to_s
.tr("\u00A0", ' ')
.gsub(/[⁰¹²³⁴⁵⁶⁷⁸⁹]/, '')
.gsub(/(?<=\p{L})\d{1,2}(?=\s|\z)/u, '')
.gsub(/\s+/, ' ')
.strip
text.presence
end
end
end
@@ -0,0 +1,156 @@
module ReportParsers
class Form11Parser < BaseParser
SHEET_NAME = 'Бухгалтерский баланс'
FORM_CODE = '1.1'
IGNORED_KEYS = [2].freeze
def call
return {} unless sheet_exists?(SHEET_NAME)
sheet = spreadsheet.sheet(SHEET_NAME)
extract_year(sheet)
build_payloads(sheet)
end
private
def build_payloads(sheet)
header_row = find_header_row(sheet)
columns = detect_columns(sheet, header_row)
# 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[data_col] = year
end
all_rows = (header_row + 1).upto(sheet.last_row).filter_map do |row|
line_name = normalize_line_name(read_cell(sheet, row, columns[:name_column]))
line_code = read_cell(sheet, row, columns[:code_column]).to_s.strip.presence
next if ignored_row?(line_name, line_code)
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?
end.compact_blank
next if line_name.blank? && line_code.blank? && values.blank?
{
line_name: line_name,
line_code: line_code,
values: values
}
end
payloads_by_year = {}
value_headers.each do |_data_col, year|
next unless year.present?
rows_for_year = all_rows.map do |row|
value = row[:values][year.to_s]
next unless value.present?
{
line_name: row[:line_name],
line_code: row[:line_code],
value: value
}
end.compact
payloads_by_year[year] = {
form_code: FORM_CODE,
sheet_name: SHEET_NAME,
header_row: header_row,
data_year: year,
rows: rows_for_year
}
end
payloads_by_year
end
def extract_year_from_header(header_text)
year_match = header_text[/\b(20\d{2})\b/]
year_match.to_i if year_match
end
def find_header_row(sheet)
1.upto(sheet.last_row) do |row|
normalized_cells = 1.upto(sheet.last_column).map { |col| normalize_label(sheet.cell(row, col)) }
has_name = normalized_cells.any? { |text| text.include?('наименование показателя') }
has_code = normalized_cells.any? { |text| text == 'код' || text.start_with?('код ') || text.start_with?('код:') }
return row if has_name && has_code
end
1
end
def detect_columns(sheet, header_row)
columns = 1.upto(sheet.last_column).each_with_object({}) do |col, hash|
hash[col] = normalize_label(sheet.cell(header_row, col))
end
name_column = columns.key(columns.values.find { |text| text.include?('наименование показателя') }) || 1
code_column = columns.key(columns.values.find { |text|
text == 'код строки' || text.start_with?('код строки ') ||
(text == 'код' && columns[name_column + 1] == text)
}) || columns.key(columns.values.find { |text| text == 'код' || text.start_with?('код ') }) || [name_column + 1, sheet.last_column].min
value_columns = columns.keys.select do |col|
text = columns[col]
text.include?('на 31') ||
text.include?('на ') ||
text.match?(/\bна \d+ /) ||
text.match?(/\b20\d{2}\b/) ||
(col > code_column && text.include?('декабря')) ||
(col > code_column && text.match?(/\d+/)) # fallback: numeric values
end
if value_columns.empty?
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: 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
yearly_file.update!(year: year) if year.positive?
end
def ignored_row?(line_name, line_code)
keys = [line_code, line_name].compact.map { |value| normalize_label(value) }
keys.any? { |key| ignored_keys.include?(key) }
end
def ignored_keys
@ignored_keys ||= IGNORED_KEYS.map { |value| normalize_label(value) }.to_set
end
end
end
@@ -0,0 +1,125 @@
module ReportParsers
class Form12Parser < BaseParser
SHEET_NAME = 'Отчет о финансовых результатах'
FORM_CODE = '1.2'
# Add line codes or names that should be skipped during parsing.
IGNORED_KEYS = [2].freeze
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_header_row(sheet)
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 = extract_year_from_header(header_text)
hash[col] = year
end
all_rows = (header_row + 1).upto(sheet.last_row).filter_map do |row|
line_name = normalize_line_name(read_cell(sheet, row, columns[:name_column]))
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|
value = read_cell(sheet, row, col)
year = value_headers[col]
hash[year.to_s] = value if value.present? && year.present?
end.compact_blank
next if line_name.blank? && line_code.blank? && values.blank?
{
line_name: line_name,
line_code: line_code,
values: values
}
end
payloads_by_year = {}
value_headers.each do |col, year|
next unless year.present?
rows_for_year = all_rows.map do |row|
value = row[:values][year.to_s]
next unless value.present?
{
line_name: row[:line_name],
line_code: row[:line_code],
value: value
}
end.compact
payloads_by_year[year] = {
form_code: FORM_CODE,
sheet_name: SHEET_NAME,
header_row: header_row,
data_year: year,
rows: rows_for_year
}
end
payloads_by_year
end
def extract_year_from_header(header_text)
year_match = header_text[/\b(20\d{2})\b/]
year_match.to_i if year_match
end
def find_header_row(sheet)
1.upto(sheet.last_row) do |row|
normalized_cells = 1.upto(sheet.last_column).map { |col| normalize_label(sheet.cell(row, col)) }
has_name = normalized_cells.any? { |text| text.include?('наименование показателя') }
has_code = normalized_cells.any? { |text| text == 'код' || text.start_with?('код ') || text.start_with?('код:') }
return row if has_name && has_code
end
1
end
def detect_columns(sheet, header_row)
columns = 1.upto(sheet.last_column).each_with_object({}) do |col, hash|
hash[col] = normalize_label(sheet.cell(header_row, col))
end
name_column = columns.key(columns.values.find { |text| text.include?('наименование показателя') }) || 1
code_column = columns.key(columns.values.find { |text|
text == 'код строки' || text.start_with?('код строки ') ||
(text == 'код' && columns[name_column + 1] == text)
}) || columns.key(columns.values.find { |text| text == 'код' || text.start_with?('код ') }) || [name_column + 1, sheet.last_column].min
value_columns = columns.keys.select do |col|
text = columns[col]
next false if text.include?('поясн')
text.match?(/20\d{2}/) || text.include?('за ') || text.include?('на ')
end
value_columns = value_columns.sort.last(2)
{
name_column: name_column,
code_column: code_column,
value_columns: value_columns
}
end
def ignored_row?(line_name, line_code)
keys = [line_code, line_name].compact.map { |value| normalize_label(value) }
keys.any? { |key| ignored_keys.include?(key) }
end
def ignored_keys
@ignored_keys ||= IGNORED_KEYS.map { |value| normalize_label(value) }.to_set
end
end
end
@@ -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
@@ -0,0 +1,123 @@
module ReportParsers
class Form14Parser < BaseParser
SHEET_NAME = 'Отчет о движении денежных средс' # отличное название листа, мне нравится
FORM_CODE = '1.4'
# Add line codes or names that should be skipped during parsing.
IGNORED_KEYS = [2].freeze
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_header_row(sheet)
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 = extract_year_from_header(header_text)
hash[col] = year
end
all_rows = (header_row + 1).upto(sheet.last_row).filter_map do |row|
line_name = normalize_line_name(read_cell(sheet, row, columns[:name_column]))
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|
value = read_cell(sheet, row, col)
year = value_headers[col]
hash[year.to_s] = value if value.present? && year.present?
end.compact_blank
next if line_name.blank? && line_code.blank? && values.blank?
{
line_name: line_name,
line_code: line_code,
values: values
}
end
payloads_by_year = {}
value_headers.each do |_, year|
next unless year.present?
rows_for_year = all_rows.map do |row|
value = row[:values][year.to_s]
next unless value.present?
{
line_name: row[:line_name],
line_code: row[:line_code],
value: value
}
end.compact
payloads_by_year[year] = {
form_code: FORM_CODE,
rows: rows_for_year
}
end
payloads_by_year
end
def extract_year_from_header(header_text)
year_match = header_text[/\b(20\d{2})\b/]
year_match.to_i if year_match
end
def find_header_row(sheet)
1.upto(sheet.last_row) do |row|
normalized_cells = 1.upto(sheet.last_column).map { |col| normalize_label(sheet.cell(row, col)) }
has_name = normalized_cells.any? { |text| text.include?('наименование показателя') }
has_code = normalized_cells.any? { |text| text == 'код' || text.start_with?('код ') || text.start_with?('код:') }
return row if has_name && has_code
end
1
end
def detect_columns(sheet, header_row)
columns = 1.upto(sheet.last_column).each_with_object({}) do |col, hash|
hash[col] = normalize_label(sheet.cell(header_row, col))
end
name_column = columns.key(columns.values.find { |text| text.include?('наименование показателя') }) || 1
code_column = columns.key(columns.values.find { |text|
text == 'код строки' || text.start_with?('код строки ') ||
(text == 'код' && columns[name_column + 1] == text)
}) || columns.key(columns.values.find { |text| text == 'код' || text.start_with?('код ') }) || [name_column + 1, sheet.last_column].min
value_columns = columns.keys.select do |col|
text = columns[col]
next false if text.include?('поясн')
text.match?(/20\d{2}/) || text.include?('за ') || text.include?('на ')
end
value_columns = value_columns.sort.last(2)
{
name_column: name_column,
code_column: code_column,
value_columns: value_columns
}
end
def ignored_row?(line_name, line_code)
keys = [line_code, line_name].compact.map { |value| normalize_label(value) }
keys.any? { |key| ignored_keys.include?(key) }
end
def ignored_keys
@ignored_keys ||= IGNORED_KEYS.map { |value| normalize_label(value) }.to_set
end
end
end
@@ -0,0 +1,73 @@
module ReportParsers
class OrganizationInfoParser < BaseParser
SHEET_NAME = 'Сведения об организации'
ORGANIZATION_LABELS = {
full_name: ['Полное наименование юридического лица'],
inn: ['ИНН'],
kpp: ['КПП'],
okpo: ['Код по ОКПО'],
okfs: ['Форма собственности (по ОКФС)'],
okopf: ['Организационно-правовая форма (по ОКОПФ)'],
okved2: ['Вид экономической деятельности по ОКВЭД 2'],
address: ['Местонахождение (адрес)']
}.freeze
AUDITOR_LABELS = {
name: ['Наименование аудиторской организации/ФИО индивидуального аудитора'],
inn: ['ИНН'],
ogrn: ['ОГРН/ОГРНИП']
}.freeze
def call
return unless sheet_exists?(SHEET_NAME)
sheet = spreadsheet.sheet(SHEET_NAME)
parse_organization(sheet)
parse_auditor(sheet)
end
private
def parse_organization(sheet)
end_row = auditor_section_start_row(sheet) ? auditor_section_start_row(sheet) - 1 : sheet.last_row
org_data = ORGANIZATION_LABELS.each_with_object({}) do |(attr, labels), hash|
hash[attr] = find_value_by_labels(sheet, labels, row_range: 1..end_row)
end
yearly_file.organization.update!(org_data)
end
def parse_auditor(sheet)
start_row = auditor_section_start_row(sheet) || 1
auditor_data = AUDITOR_LABELS.each_with_object({}) do |(attr, labels), hash|
hash[attr] = find_value_by_labels(sheet, labels, row_range: start_row..sheet.last_row)
end
return if auditor_data[:name].blank?
auditor_inn = auditor_data[:inn].to_s.strip
auditor = if auditor_inn.present?
Auditor.find_or_initialize_by(inn: auditor_inn)
else
Auditor.find_or_initialize_by(name: auditor_data[:name])
end
auditor.assign_attributes(
name: auditor_data[:name],
inn: auditor_inn.presence,
ogrn: auditor_data[:ogrn].to_s.strip.presence
)
auditor.save!
yearly_file.update!(auditor: auditor)
end
def auditor_section_start_row(sheet)
@auditor_section_start_row ||= begin
labels = AUDITOR_LABELS.fetch(:name)
find_label_position(sheet, labels, row_range: 1..sheet.last_row)&.first
end
end
end
end
+34 -6
View File
@@ -6,20 +6,38 @@ 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)
# Найти или создать организацию по ИНН
organization = Organization.find_or_create_by!(inn: inn)
Dir.glob("#{temp_dir}/**/*").select { |f| File.file?(f) }.each do |file_path|
# Прикрепляем файл к Report (обратная совместимость)
report.files.attach(
io: File.open(file_path),
filename: File.basename(file_path)
)
# Создаём YearlyFile и прикрепляем xlsx
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)
)
yearly_file.save!
# Парсим данные из xlsx
ReportParser.new(yearly_file).call
end
# Удали временные файлы
@@ -42,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"\
+7
View File
@@ -0,0 +1,7 @@
.main-header
= 'Отчетность за 2026 год'
.exit
- if logged_in?
a.link[href="/logout"]
span.text-large
= 'Выйти'
+6
View File
@@ -0,0 +1,6 @@
- if @current_user.present?
.nav-container
a.link[href="/"]
= 'Организации'
a.link[href="/auditors/index"]
= 'Аудиторы'
+13
View File
@@ -0,0 +1,13 @@
.header
.header-title
= "Аудиторы"
.main-container
- unless @info_about_auditors.any?
= "Аудиторы не найдены"
- else
.informations-data
- @info_about_auditors.each do |auditor, params|
.information-data
- params.keys.each do |key|
.information-elem
= "#{key}: #{params[key]}"
+4 -4
View File
@@ -2,18 +2,18 @@ doctype html
html
head
title
=t('app_name')
= "Отчетность 2026"
meta[name="viewport" content="width=device-width,initial-scale=1"]
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"]
= javascript_importmap_tags
= hotwire_livereload_tags if Rails.env.development?
body
= render 'header'
= render 'navbar'
= yield
+20
View File
@@ -0,0 +1,20 @@
.header
.header-title
= "Организации"
.main-container
.add-new
a.link[href="/reports"]
= "Добавить новую организацию"
- unless @info_about_organisations.any?
= 'Организации не найдены'
- else
.informations-data
- @info_about_organisations.each do |organisation, params|
.information-data
- params.keys.each do |key|
.information-elem
= "#{key}: #{params[key]}"
.information-button
a.link[href="/organisations/#{organisation}"]
= "Подробнее"
== pagy_nav(@pagy)
+59
View File
@@ -0,0 +1,59 @@
.header
.header-title
= "Организация"
.main-container
- 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
= "Сведения об организации"
.information-data
- @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
| &nbsp;Обновить данные
= link_to "/organisations/#{@org.id}",
class: 'link-action link-action--danger',
data: { turbo_method: :delete, turbo_confirm: 'Удалить организацию и все её данные? Это действие необратимо.' } do
i.bi.bi-trash
| &nbsp;Удалить
= render 'organisations/templates/type_doc', target: "type_doc_#{@type_doc}", org_id: "#{@org.id}"
h2
= "#{@doc_name}"
- if @current_doc_info.present?
.information-table
table
thead
tr
- @current_doc_info.each do |header|
th = header
tbody
- @current_doc_info1.keys.each do |key|
tr
td = key[0]
td = key[1]
-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 = "Нет данных"
@@ -0,0 +1,16 @@
.main__container
.type_doc_header#tabs
.type__doc__list__type__container
a.link[href="/organisations/#{org_id}?type_doc=0"]
.switch__button.text-large#type_doc_0 data-value=target
= 'Бухгалтерский баланс'
a.link[href="/organisations/#{org_id}?type_doc=1"]
.switch__button.text-large#type_doc_1 data-value=target
= 'Отчёт о финансовых результатах'
/ - if not_simplified
a.link[href="/organisations/#{org_id}?type_doc=2"]
.switch__button.text-large#type_doc_2 data-value=target
= 'Отчёт об изменении капитала'
a.link[href="/organisations/#{org_id}?type_doc=3"]
.switch__button.text-large#type_doc_3 data-value=target
= 'Отчёт о движении денежных средств'
+5
View File
@@ -1,5 +1,10 @@
<!-- app/views/reports/index.html.erb -->
<h1>Загрузка отчётов по ИНН</h1>
<div>
<p>
Тестовые ИНН: <br>Полные отчеты: 7707602010, 9705114405<br>Упрощенка: 7724298987
</p>
</div>
<% if notice %>
<p style="color: green;"><%= notice %></p>
+13
View File
@@ -0,0 +1,13 @@
.welcome__container__laptop
div
= 'Информационная система'
div
= '«Электронный дневник»'
- unless logged_in?
a.link[href="/auth/login"]
.welcome-login__container
span
= image_tag('auth.svg', class: 'icon')
span.text-login
= 'Войти'
+1 -1
View File
@@ -1 +1 @@
Nc1hSplQ0MqAyalUwEkhP4MHvgHWBdQ1asGv87citiRh0CbxU5jWK0Lue0mz1IgZfkeHK/Tk1hMRgvBraTADvRbQNW1F49wW6KM/A3JSbviimeFMhwpNeX4Td4gqGNCdynu6etedjxDNjmDVvL9ADgq2ekVJR4ILXiJOarH1Ke9xF1Ep/4NAo3ce8Ec7h9Rphk16V8sLzIQ1J+dR1gJEVAPuGElftRXxRrWx8Q6pM+iAjd/7XdqpDhhmqNZwl2b/2w0pmy1MDeAuSoEFK8VtTqKW+e8MfR7iSMX8p6FCBr0BIqlrnthmzr/et3o03X9ZxAnrWrWk+hECMPLri2hW+w0r/JuGZjBahCr3PF/MHggtx0GJNmbnxb18TRPje3hQjDZ1ro0WorO6EdoH6Tsaby+IAwIknx7WLd5N--H3c/XDWT/kou9ABR--loDWauLHnUsVDLFdegRueA==
vzvJyboAlEsDCJYPDUPlvQefU89OZO14EHm+KkZc/SXKOKak0o0h6eMpA50DWzlMqgLppOuZqg2Y4lLCSIicLEjenP9N5fhmRDDGZmMe2OtUUYo8KOOAggbV93xL/He1GbFv0g0FPljIgn3dZvesQDQeU+/Hvmip4t9d7xSIq5ztA1ljraPmT4fasc8NvPElY6HN5YuuivvDZEXrb9Ck+5o5gmLS+KHKWqEygVpnLCgXVbVFVhuNbmAJ+C/pLvPDpzDgtkbEMhMr+1B8HGyBauan5CK5afW6AUPl5hVVr1LoefHL1ZY3pl1f/4JGJijfS1Fl3E0HgGyD7etwW4s1nn5IsVhF1QzHkULAtCGTvTBMTcdrhtxvrrDu4cYUuTZ8/FnghLbw1rNdEGR4Ic00lY7Nbkzg--6FKykVUCPO92dm87--85MEOy/jUeU6+C7IezR8hQ==
+40 -29
View File
@@ -1,39 +1,50 @@
# config valid for current version and patch releases of Capistrano
lock "~> 3.19.2"
set :application, "my_app_name"
set :repo_url, "git@example.com:me/my_repo.git"
set :application, "finreport-analyzer"
set :repo_url, "https://git.ada-dev.ru/ada/finreport-analyzer.git"
# Default branch is :master
# ask :branch, `git rev-parse --abbrev-ref HEAD`.chomp
set :deploy_to, "/var/www/finreport-analyzer"
set :branch, "develop"
# Default deploy_to directory is /var/www/my_app_name
# set :deploy_to, "/var/www/my_app_name"
set :rvm_type, :user
set :rvm_ruby_version, "3.3.0"
# Default value for :format is :airbrussh.
# set :format, :airbrussh
append :linked_files, "config/database.yml"
append :linked_dirs, "log", "tmp/pids", "tmp/cache", "tmp/sockets",
"public/system", "storage"
# You can configure the Airbrussh format using :format_options.
# These are the defaults.
# set :format_options, command_output: true, log_file: "log/capistrano.log", color: :auto, truncate: :auto
set :keep_releases, 5
# Default value for :pty is false
# set :pty, true
set :puma_threads, [2, 4]
set :puma_workers, 2
set :puma_bind, "unix://#{shared_path}/tmp/sockets/puma.sock"
set :puma_state, "#{shared_path}/tmp/pids/puma.state"
set :puma_pid, "#{shared_path}/tmp/pids/puma.pid"
set :puma_access_log, "#{release_path}/log/puma.access.log"
set :puma_error_log, "#{release_path}/log/puma.error.log"
set :puma_preload_app, true
set :puma_systemctl_user, :system
set :puma_service_unit_name, "puma-finreport"
# Default value for :linked_files is []
# append :linked_files, "config/database.yml", 'config/master.key'
namespace :sidekiq do
task :restart do
on roles(:app) do
execute :sudo, "systemctl restart sidekiq-finreport", raise_on_non_zero_exit: false
end
end
end
# Default value for linked_dirs is []
# append :linked_dirs, "log", "tmp/pids", "tmp/cache", "tmp/sockets", "public/system", "vendor", "storage"
after "deploy:finished", "sidekiq:restart"
after "deploy:migrate", "deploy:seed"
# Default value for default_env is {}
# set :default_env, { path: "/opt/ruby/bin:$PATH" }
# Default value for local_user is ENV['USER']
# set :local_user, -> { `git config user.name`.chomp }
# Default value for keep_releases is 5
# set :keep_releases, 5
# Uncomment the following to require manually verifying the host key before first deploy.
# set :ssh_options, verify_host_key: :secure
namespace :deploy do
task :seed do
on roles(:db) do
within release_path do
with rails_env: fetch(:rails_env, "production") do
execute :bundle, "exec rake db:seed"
end
end
end
end
end
+7 -60
View File
@@ -1,61 +1,8 @@
# server-based syntax
# ======================
# Defines a single server with a list of roles and multiple properties.
# You can define all roles on a single server, or split them:
server "finreport-prod",
user: "deploy",
roles: %w[app db web]
# server "example.com", user: "deploy", roles: %w{app db web}, my_property: :my_value
# server "example.com", user: "deploy", roles: %w{app web}, other_property: :other_value
# server "db.example.com", user: "deploy", roles: %w{db}
# role-based syntax
# ==================
# Defines a role with one or multiple servers. The primary server in each
# group is considered to be the first unless any hosts have the primary
# property set. Specify the username and a domain or IP for the server.
# Don't use `:all`, it's a meta role.
# role :app, %w{deploy@example.com}, my_property: :my_value
# role :web, %w{user1@primary.com user2@additional.com}, other_property: :other_value
# role :db, %w{deploy@example.com}
# Configuration
# =============
# You can set any configuration variable like in config/deploy.rb
# These variables are then only loaded and set in this stage.
# For available Capistrano configuration variables see the documentation page.
# http://capistranorb.com/documentation/getting-started/configuration/
# Feel free to add new variables to customise your setup.
# Custom SSH Options
# ==================
# You may pass any option but keep in mind that net/ssh understands a
# limited set of options, consult the Net::SSH documentation.
# http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start
#
# Global options
# --------------
# set :ssh_options, {
# keys: %w(/home/user_name/.ssh/id_rsa),
# forward_agent: false,
# auth_methods: %w(password)
# }
#
# The server-based syntax can be used to override options:
# ------------------------------------
# server "example.com",
# user: "user_name",
# roles: %w{web app},
# ssh_options: {
# user: "user_name", # overrides user setting above
# keys: %w(/home/user_name/.ssh/id_rsa),
# forward_agent: false,
# auth_methods: %w(publickey password)
# # password: "please use keys"
# }
set :ssh_options, {
forward_agent: true,
auth_methods: %w[publickey]
}
+1
View File
@@ -12,3 +12,4 @@ pin "jquery_ujs", to: "jquery_ujs.js", preload: true
pin "jquery-ui", to: "jquery-ui.js", preload: true
pin "inputmask", to: "jquery.inputmask.bundle.min.js", preload: true
pin 'slimselect', to: 'slimselect.min.js', preload: true
pin "tabs_select", to: 'tabs_select.js', preload: true
+8 -2
View File
@@ -1,10 +1,16 @@
# require 'sidekiq/web'
Rails.application.routes.draw do
get 'reports/create'
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'
post '/reports', to: 'reports#create'
# mount Sidekiq::Web => '/sidekiq' # Для проверки работы Sidekiq через веб-интерфейс
@@ -0,0 +1,18 @@
class CreateOrganizations < ActiveRecord::Migration[7.1]
def change
create_table :organizations do |t|
t.string :inn, null: false
t.string :full_name
t.string :kpp
t.string :okpo
t.string :okfs
t.string :okopf
t.string :okved2
t.string :address
t.timestamps
end
add_index :organizations, :inn
end
end
@@ -0,0 +1,13 @@
class CreateAuditors < ActiveRecord::Migration[7.1]
def change
create_table :auditors do |t|
t.string :name
t.string :inn
t.string :ogrn
t.timestamps
end
add_index :auditors, :inn
end
end
@@ -0,0 +1,9 @@
class CreateDocumentTypes < ActiveRecord::Migration[7.1]
def change
create_table :document_types do |t|
t.string :name, null: false
t.timestamps
end
end
end
@@ -0,0 +1,11 @@
class CreateYearlyFiles < ActiveRecord::Migration[7.1]
def change
create_table :yearly_files do |t|
t.references :organization, null: false, foreign_key: true
t.integer :year, null: false
t.references :auditor, foreign_key: true
t.timestamps
end
end
end
@@ -0,0 +1,12 @@
class CreateFinancialStatements < ActiveRecord::Migration[7.1]
def change
create_table :financial_statements do |t|
t.references :yearly_file, null: false, foreign_key: true
t.references :document_type, null: false, foreign_key: true
t.integer :sheet_number
t.jsonb :data, default: {}
t.timestamps
end
end
end
@@ -0,0 +1,9 @@
class AddUniqueIndexToFinancialStatements < ActiveRecord::Migration[7.1]
def change
add_index :financial_statements,
[:yearly_file_id, :document_type_id, :sheet_number],
name: 'index_fs_on_yearly_file_doc_type_sheet',
unique: true,
if_not_exists: true
end
end
@@ -0,0 +1,18 @@
class SeedDocumentTypes < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
INSERT INTO document_types (id, name, created_at, updated_at) VALUES
(1, 'Бухгалтерский баланс', NOW(), NOW()),
(2, 'Отчет о финансовых результатах', NOW(), NOW()),
(3, 'Отчет об изменениях капитала', NOW(), NOW()),
(4, 'Отчет о движении денежных средств', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
SQL
end
def down
execute <<-SQL
DELETE FROM document_types WHERE id IN (1,2,3,4);
SQL
end
end
@@ -0,0 +1,13 @@
class CreateReportCodes < ActiveRecord::Migration[7.1]
def change
create_table :report_codes do |t|
t.string :code, null: false
t.string :name, null: false
t.text :description, null: false, default: ''
t.timestamps
end
add_index :report_codes, :code, unique: true
end
end
@@ -0,0 +1,290 @@
class SeedReportCodes < ActiveRecord::Migration[7.1]
def up
rows = [
{ code: "1105", 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: "Краткосрочное имущество, не включенное в другие строки раздела 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: "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: "Обязательства с неопределенной суммой или сроком исполнения (< 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: "3100", name: "Величина капитала на 31 декабря года, предшествующего предыдущему", description: "-" },
{ code: "3110", name: "Корректировка в связи с изменением учетной политики", description: "-" },
{ code: "3120", name: "Корректировка в связи с исправлением ошибок", description: "-" },
{ code: "3101", name: "Величина капитала на 31 декабря года, предшествующего предыдущему, после корректировки", description: "-" },
{ code: "3211", name: "Чистая прибыль (убыток)", description: "-" },
{ code: "3212", name: "Переоценка внеоборотных активов", description: "-" },
{ code: "3227", name: "Дивиденды", description: "-" },
{ code: "3230", name: "Иные изменения за счет операций с собственниками (за исключением дивидендов) - всего", description: "-" },
{ code: "3216", name: "Реорганизация юридического лица", description: "-" },
{ code: "3240", name: "Иные изменения - всего", description: "-" },
{ code: "3250", name: "Величина капитала на дату окончания периода предыдущего года, аналогичного отчетному периоду", description: "-" },
{ code: "3200", name: "Величина капитала на 31 декабря предыдущего года", description: "-" },
{ code: "3210", name: "Корректировка в связи с изменением учетной политики", description: "-" },
{ code: "3220", name: "Корректировка в связи с исправлением ошибок", description: "-" },
{ code: "3201", name: "Величина капитала на 31 декабря предыдущего года после корректировки", description: "-" },
{ code: "3311", name: "Чистая прибыль (убыток)", description: "-" },
{ code: "3312", name: "Переоценка внеоборотных активов", description: "-" },
{ code: "3327", name: "Дивиденды", description: "-" },
{ code: "3330", name: "Иные изменения за счет операций с собственниками (за исключением дивидендов) - всего", description: "-" },
{ code: "3316", name: "Реорганизация юридического лица", description: "-" },
{ code: "3340", name: "Иные изменения - всего", description: "-" },
{ code: "3300", name: "Величина капитала на отчетную дату", description: "-" },
{ code: "4110", name: "Поступления - всего", description: "-" },
{ code: "4111", name: "От продажи продукции, товаров, выполнения работ, оказания услуг", description: "-" },
{ code: "4112", name: "Арендных платежей, роялти, комиссионных и иных аналогичных платежей", description: "в-" },
{ code: "4113", name: "От перепродажи финансовых вложений", description: "-" },
{ code: "4114", name: "Процентов по дебиторской задолженности покупателей", description: "-" },
{ code: "4119", name: "Прочие поступления", description: "-" },
{ code: "4120", name: "Платежи - всего", description: "-" },
{ code: "4121", name: "Поставщикам (подрядчикам) за сырье, материалы, выполненные работы, оказанные услуги", description: "в-" },
{ code: "4122", name: "В связи с оплатой труда работников", description: "-" },
{ code: "4123", name: "Процентов по долговым обязательствам", description: "-" },
{ code: "4124", name: "Налога на прибыль организаций", description: "-" },
{ code: "4129", name: "Прочие платежи", description: "-" },
{ code: "4100", name: "Сальдо денежных потоков от текущих операций", description: "-" },
{ code: "4210", name: "Поступления - всего", description: "-" },
{ code: "4211", name: "От продажи внеоборотных активов (кроме финансовых вложений)", description: "-" },
{ code: "4212", name: "От продажи акций других организаций (долей участия)", description: "-" },
{ code: "4213", name: "От возврата предоставленных займов, от продажи долговых ценных бумаг (прав требования денежных средств к другим лицам)", description: "-" },
{ code: "4214", name: "Дивидендов, процентов по долговым финансовым вложениям и аналогичных поступлений от долевого участия в других организациях", description: "-" },
{ code: "4219", name: "Прочие поступления", description: "-" },
{ code: "4220", name: "Платежи - всего", description: "-" },
{ code: "4221", name: "В связи с приобретением, созданием, модернизацией, реконструкцией и подготовкой к использованию внеоборотных активов", description: "-" },
{ code: "4222", name: "В связи с приобретением акций других организаций (долей участия)", description: "-" },
{ code: "4223", name: "В связи с приобретением долговых ценных бумаг (прав требования денежных средств к другим лицам), предоставление займов другим лицам", description: "-" },
{ code: "4224", name: "Процентов по долговым обязательствам, включаемым в стоимость инвестиционного актива", description: "-" },
{ code: "4229", name: "Прочие платежи", description: "-" },
{ code: "4200", name: "Сальдо денежных потоков от инвестиционных операций", description: "-" },
{ code: "4310", name: "Поступления - всего", description: "-" },
{ code: "4311", name: "Получение кредитов и займов", description: "-" },
{ code: "4312", name: "Денежных вкладов собственников (участников)", description: "-" },
{ code: "4313", name: "От выпуска акций, увеличения долей участия", description: "-" },
{ code: "4314", name: "От выпуска облигаций, векселей и других долговых ценных бумаг", description: "-" },
{ code: "4319", name: "Прочие поступления", description: "-" },
{ code: "4320", name: "Платежи - всего", description: "-" },
{ code: "4321", name: "Собственникам (участникам) в связи с выкупом у них акций (долей участия) организации или их выходом из состава участников", description: "-" },
{ code: "4322", name: "На уплату дивидендов и иных платежей по распределению прибыли в пользу собственников (участников)", description: "-" },
{ code: "4323", name: "В связи с погашением (выкупом) векселей и других долговых ценных бумаг, возврат кредитов и займов", description: "-" },
{ code: "4329", name: "Прочие платежи", description: "-" },
{ code: "4300", name: "Сальдо денежных потоков от финансовых операций", description: "-" },
{ code: "4400", name: "Сальдо денежных потоков за период", description: "-" },
{ code: "4450", name: "Остаток денежных средств и денежных эквивалентов на начало периода", description: "-" },
{ code: "4500", name: "Остаток денежных средств и денежных эквивалентов на конец периода", description: "-" },
{ code: "4490", name: "Величина влияния изменения курса иностранной валюты по отношению к рублю", description: "-" }
]
return if rows.empty?
rows.each do |row|
execute <<~SQL
INSERT INTO report_codes (code, name, description, created_at, updated_at)
VALUES (
#{connection.quote(row.fetch(:code))},
#{connection.quote(row.fetch(:name))},
#{connection.quote(row.fetch(:description, ''))},
NOW(),
NOW()
)
ON CONFLICT (code)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
updated_at = NOW();
SQL
end
end
def down
codes = [
"1100",
"1105",
"1110",
"1130",
"1140",
"1150",
"1160",
"1170",
"1180",
"1190",
"1200",
"1210",
"1215",
"1220",
"1230",
"1240",
"1250",
"1260",
"1600",
"1300",
"1310",
"1320",
"1330",
"1340",
"1350",
"1360",
"1370",
"1400",
"1410",
"1420",
"1430",
"1450",
"1500",
"1510",
"1520",
"1530",
"1540",
"1550",
"2110",
"2120",
"2100",
"2210",
"2220",
"2200",
"2310",
"2320",
"2330",
"2340",
"2350",
"2300",
"2410",
"2411",
"2412",
"2420",
"2460",
"2400",
"2510",
"2520",
"2530",
"2500",
"2900",
"2910",
"3100",
"3110",
"3120",
"3101",
"3211",
"3212",
"3227",
"3230",
"3216",
"3240",
"3250",
"3200",
"3210",
"3220",
"3201",
"3311",
"3312",
"3327",
"3330",
"3316",
"3340",
"3300",
"4110",
"4111",
"4112",
"4113",
"4114",
"4119",
"4120",
"4121",
"4122",
"4123",
"4124",
"4129",
"4100",
"4210",
"4211",
"4212",
"4213",
"4214",
"4219",
"4220",
"4221",
"4222",
"4223",
"4224",
"4229",
"4200",
"4310",
"4311",
"4312",
"4313",
"4314",
"4319",
"4320",
"4321",
"4322",
"4323",
"4329",
"4300",
"4400",
"4450",
"4500",
"4490"
]
return if codes.empty?
quoted_codes = codes.map { |code| connection.quote(code) }.join(', ')
execute "DELETE FROM report_codes WHERE code IN (#{quoted_codes});"
end
end
@@ -0,0 +1,39 @@
class RemoveDocumentTypesFromFinancialStatements < ActiveRecord::Migration[7.1]
def up
remove_index :financial_statements, name: 'index_fs_on_yearly_file_doc_type_sheet', if_exists: true
remove_index :financial_statements, :document_type_id, if_exists: true
remove_foreign_key :financial_statements, :document_types if foreign_key_exists?(:financial_statements, :document_types)
remove_column :financial_statements, :document_type_id if column_exists?(:financial_statements, :document_type_id)
add_index :financial_statements,
[:yearly_file_id, :sheet_number],
name: 'index_fs_on_yearly_file_sheet',
unique: true,
if_not_exists: true
drop_table :document_types, if_exists: true
end
def down
create_table :document_types do |t|
t.string :name, null: false
t.timestamps
end unless table_exists?(:document_types)
add_column :financial_statements, :document_type_id, :bigint unless column_exists?(:financial_statements, :document_type_id)
add_foreign_key :financial_statements, :document_types unless foreign_key_exists?(:financial_statements, :document_types)
add_index :financial_statements, :document_type_id, if_not_exists: true
remove_index :financial_statements, name: 'index_fs_on_yearly_file_sheet', if_exists: true
add_index :financial_statements,
[:yearly_file_id, :document_type_id, :sheet_number],
name: 'index_fs_on_yearly_file_doc_type_sheet',
unique: true,
if_not_exists: true
end
end
Generated
+56 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_12_09_142642) do
ActiveRecord::Schema[7.1].define(version: 2026_04_18_113000) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -42,6 +42,48 @@ ActiveRecord::Schema[7.1].define(version: 2025_12_09_142642) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "auditors", force: :cascade do |t|
t.string "name"
t.string "inn"
t.string "ogrn"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["inn"], name: "index_auditors_on_inn"
end
create_table "financial_statements", force: :cascade do |t|
t.bigint "yearly_file_id", null: false
t.integer "sheet_number"
t.jsonb "data", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["yearly_file_id", "sheet_number"], name: "index_fs_on_yearly_file_sheet", unique: true
t.index ["yearly_file_id"], name: "index_financial_statements_on_yearly_file_id"
end
create_table "organizations", force: :cascade do |t|
t.string "inn", null: false
t.string "full_name"
t.string "kpp"
t.string "okpo"
t.string "okfs"
t.string "okopf"
t.string "okved2"
t.string "address"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["inn"], name: "index_organizations_on_inn"
end
create_table "report_codes", force: :cascade do |t|
t.string "code", null: false
t.string "name", null: false
t.text "description", default: "", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_report_codes_on_code", unique: true
end
create_table "reports", force: :cascade do |t|
t.string "inn"
t.string "status"
@@ -76,6 +118,19 @@ ActiveRecord::Schema[7.1].define(version: 2025_12_09_142642) do
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end
create_table "yearly_files", force: :cascade do |t|
t.bigint "organization_id", null: false
t.integer "year", null: false
t.bigint "auditor_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["auditor_id"], name: "index_yearly_files_on_auditor_id"
t.index ["organization_id"], name: "index_yearly_files_on_organization_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "financial_statements", "yearly_files"
add_foreign_key "yearly_files", "auditors"
add_foreign_key "yearly_files", "organizations"
end
+15
View File
@@ -0,0 +1,15 @@
require 'rails_helper'
# Specs in this file have access to a helper object that includes
# the AuditorsHelper. For example:
#
# describe AuditorsHelper do
# describe "string concat" do
# it "concats two strings with spaces" do
# expect(helper.concat_strings("this","that")).to eq("this that")
# end
# end
# end
RSpec.describe AuditorsHelper, type: :helper do
pending "add some examples to (or delete) #{__FILE__}"
end
+15
View File
@@ -0,0 +1,15 @@
require 'rails_helper'
# Specs in this file have access to a helper object that includes
# the OrganisationsHelper. For example:
#
# describe OrganisationsHelper do
# describe "string concat" do
# it "concats two strings with spaces" do
# expect(helper.concat_strings("this","that")).to eq("this that")
# end
# end
# end
RSpec.describe OrganisationsHelper, type: :helper do
pending "add some examples to (or delete) #{__FILE__}"
end
+11
View File
@@ -0,0 +1,11 @@
require 'rails_helper'
RSpec.describe "Auditors", type: :request do
describe "GET /index" do
it "returns http success" do
get "/auditors/index"
expect(response).to have_http_status(:success)
end
end
end
+18
View File
@@ -0,0 +1,18 @@
require 'rails_helper'
RSpec.describe "Organisations", type: :request do
describe "GET /index" do
it "returns http success" do
get "/organisations/index"
expect(response).to have_http_status(:success)
end
end
describe "GET /show" do
it "returns http success" do
get "/organisations/show"
expect(response).to have_http_status(:success)
end
end
end
@@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe "auditors/index.html.tailwindcss", type: :view do
pending "add some examples to (or delete) #{__FILE__}"
end
@@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe "organisations/index.html.tailwindcss", type: :view do
pending "add some examples to (or delete) #{__FILE__}"
end
@@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe "organisations/show.html.tailwindcss", type: :view do
pending "add some examples to (or delete) #{__FILE__}"
end