Compare commits

...

110 Commits

Author SHA1 Message Date
0b451b2499 [AURORA-894] - фикс удаления строк 2026-07-28 09:50:35 +03:00
3a2bb6c8aa Merge pull request 'form-export-fix' (#49) from form-export-fix into test
Reviewed-on: #49
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-27 14:00:05 +03:00
45710ff58e form-export-fix: убрал экспорт страницы OTCH9F, кириллические названия страниц 2026-07-27 13:58:37 +03:00
efe6df992d Merge pull request 'form-4-sql: добавление программ и проектов в форму 4' (#48) from form-4-sql into test
Reviewed-on: #48
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-27 10:11:52 +03:00
15477cf6ce Merge pull request 'form-4-expense-item-filters: фильтры для expense-item' (#45) from form-4-expense-item-filters into test
Reviewed-on: #45
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-27 10:11:39 +03:00
68fd2a7646 form-4-sql: добавление программ и проектов в форму 4 2026-07-27 10:01:51 +03:00
18ec053e44 Merge pull request 'правки' (#47) from table-optimization-fix into test
Reviewed-on: #47
2026-07-24 17:28:19 +03:00
PotapovaA
e4726b6284 правки 2026-07-24 17:26:05 +03:00
e6133b82fe Merge pull request 'Оптимизация' (#46) from table-optimization into test
Reviewed-on: #46
2026-07-24 14:14:46 +03:00
PotapovaA
3c7aafc8bb Оптимизация 2026-07-24 12:58:15 +03:00
ed0e7c805b form-4-expense-item-filters: фильтры для expense-item 2026-07-21 17:38:16 +03:00
9f652e5b94 Merge pull request 'entity-type-hotfix: поправил миграцию с entity-type' (#44) from entity-type-hotfix into test
Reviewed-on: #44
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-21 16:03:46 +03:00
0aa053c0c4 entity-type-hotfix: поправил миграцию с entity-type 2026-07-21 15:45:15 +03:00
74d191ba20 Merge pull request 'form-3-phases-timezones: редактирвоание этапов для 3 формы, таймзоны для этапов 3 формы, поправил entity_type для этапов' (#43) from form-3-phases-timezones into test
Reviewed-on: #43
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-21 15:04:24 +03:00
c976ae8c92 form-3-phases-timezones: редактирвоание этапов для 3 формы, таймзоны для этапов 3 формы, поправил entity_type для этапов 2026-07-21 15:00:36 +03:00
28ce0c1511 Merge pull request 'правки audit logs' (#42) from fixes-front-sort-order into test
Reviewed-on: #42
2026-07-20 18:41:20 +03:00
PotapovaA
225c68c4ac правки audit logs 2026-07-20 18:41:02 +03:00
7c64938020 Merge pull request 'form-4-handlers: добавил ручку со списком expense item и фильтрацией по полю r_start' (#41) from form-4-handlers into test
Reviewed-on: #41
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-20 15:47:17 +03:00
ad7b374fec Merge pull request 'export-fix: поправил экспорт страниц безопасность.френда.коммунальные платежи' (#40) from export-fix into test
Reviewed-on: #40
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-07-20 15:47:07 +03:00
ab77ce458d form-4-handlers: добавил ручку со списком expense item и фильтрацией по полю r_start 2026-07-20 14:45:01 +03:00
12d0be1351 export-fix: поправил экспорт страниц безопасность.френда.коммунальные платежи 2026-07-20 14:08:17 +03:00
79bf3231b9 Merge pull request 'fixes-front-sort-order' (#39) from fixes-front-sort-order into test
Reviewed-on: #39
2026-07-20 12:07:17 +03:00
PotapovaA
34d5fbc247 дата в фильтрации audit logs 2026-07-20 12:06:26 +03:00
PotapovaA
096a8b817f оптимизацмя таблицы 2026-07-20 11:30:51 +03:00
PotapovaA
dc621a1223 фикс sort-order при удалении/добавлении 2026-07-17 17:18:26 +03:00
022aa7b186 Merge pull request 'заголовки' (#38) from header-4-form into test
Reviewed-on: #38
2026-07-17 16:09:28 +03:00
PotapovaA
c7f4fed6f6 заголовки 2026-07-17 16:04:52 +03:00
e5e029e763 Merge pull request 'close-date-vsp-fix: дата окончания договора не может быть менее даты открытия' (#36) from close-date-vsp-fix into test
Reviewed-on: #36
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-17 09:57:59 +03:00
520d64c04d Merge pull request 'deleted-user-auth-fix: удаленный пользователь не может авторизоваться' (#37) from deleted-user-auth-fix into test
Reviewed-on: #37
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-17 09:57:47 +03:00
bad774f162 close-date-vsp-fix: дата окончания договора не может быть менее даты открытия 2026-07-16 16:40:48 +03:00
c9346e5219 Merge pull request 'форма 4' (#35) from form-4 into test
Reviewed-on: #35
2026-07-16 15:50:20 +03:00
PotapovaA
c34825d113 форма 4 2026-07-16 15:49:49 +03:00
2a88240dd9 deleted-user-auth-fix: удаленный пользователь не может авторизоваться 2026-07-16 14:40:34 +03:00
ab2c1151fa Merge pull request 'smeta-fix: добавил констрейнт для ограничения числв форм на ссп' (#34) from smeta-fix into test
Reviewed-on: #34
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-16 11:35:05 +03:00
c8dbba7e57 smeta-fix: добавил констрейнт для ограничения числв форм на ссп 2026-07-15 13:48:26 +03:00
f27ea41875 Merge pull request 'front-fixes' (#33) from front-fixes into test
Reviewed-on: #33
2026-07-14 17:28:32 +03:00
PotapovaA
e06b2dc4d4 aurora-1194 2026-07-14 17:27:29 +03:00
PotapovaA
2f505c7051 AURORA-1173 2026-07-14 15:26:30 +03:00
b07e02e643 Merge pull request 'line_id_code_full' (#32) from line_id_code_full into test
Reviewed-on: #32
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-14 15:10:31 +03:00
PotapovaA
580d9db311 AURORA-865 2026-07-14 14:51:51 +03:00
f0358f640d line_id_code_full: блокируем ячейку по line_id_code, не позволяем редактировать ячейку, если не было cell_edit_start 2026-07-14 13:56:50 +03:00
PotapovaA
27e79a3df5 добавила line_id_code в cell_updated 2026-07-14 13:51:42 +03:00
569138530d Merge pull request 'timezones: таймзоны для этапов, правки в бд для формы 3' (#31) from timezones into test
Reviewed-on: #31
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-14 10:04:38 +03:00
b664a39469 timezones: таймзоны для этапов, правки в бд для формы 3 2026-07-14 09:06:25 +03:00
857827813b Merge pull request 'filenames-fix: пофиксил названия файлов, вызывающих ошибку при рендере таблицы' (#30) from filenames-fix into test
Reviewed-on: #30
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-09 16:04:31 +03:00
ec73ab9a2b filenames-fix: пофиксил названия файлов, вызывающих ошибку при рендере таблицы 2026-07-09 14:46:05 +03:00
5537b8229e Merge pull request 'AURORA-1174-73' (#29) from AURORA-1174-73 into test
Reviewed-on: #29
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-07-09 10:30:22 +03:00
Raykov-MS
5eb73ddb37 clean 004 migration 2026-07-08 12:54:17 +03:00
Raykov-MS
4d5a39ef12 Merge remote-tracking branch 'origin/test' into AURORA-1174-73 2026-07-08 12:50:26 +03:00
Raykov-MS
ccba554b86 solve bugs 2026-07-08 12:50:21 +03:00
c4dd35b6bd Merge pull request 'project-sheets: год опциональный с null' (#28) from project-sheets into test
Reviewed-on: #28
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 15:50:43 +03:00
ffe4ff9edb Merge pull request 'Выпадает всп с не наступившей датой закрытия' (#26) from AURORA-1166 into test
Reviewed-on: #26
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-07-03 15:42:57 +03:00
0a99aeed0c project-sheets: год опциональный с null 2026-07-03 15:35:20 +03:00
8a280781bb Merge pull request 'front-projects' (#27) from front-projects into test
Reviewed-on: #27
2026-07-03 15:08:03 +03:00
PotapovaA
88e12d1a8b сделала год в проект оптимальным 2026-07-03 15:07:44 +03:00
PotapovaA
6f0b3eb0cc Merge branch 'test' into front-projects 2026-07-03 13:31:43 +03:00
PotapovaA
97934a3404 Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-03 13:31:24 +03:00
PotapovaA
0b33ba2b40 Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-03 13:31:21 +03:00
Raykov-MS
5ddf7479a4 Выпадает всп с не наступившей датой закрытия 2026-07-03 13:28:39 +03:00
PotapovaA
bc4e5122c6 Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-03 13:27:51 +03:00
PotapovaA
acd4645a4e fix aurora-1149 2026-07-03 12:46:05 +03:00
bdb4c389ce Merge pull request 'form3-permissions: фазы для 3 фрмы, правки фикстуры' (#22) from form3-permissions into test
Reviewed-on: #22
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 08:04:36 +03:00
5e6ad47128 Merge pull request 'websocket-projects: возвращается line_id, ошибка при удалении строки' (#23) from websocket-projects into test
Reviewed-on: #23
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 08:04:12 +03:00
19dd443146 Merge pull request 'projects-sheets: года в списке проектов, опциональный год при создании проекта' (#24) from project-sheets into test
Reviewed-on: #24
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 08:03:52 +03:00
PotapovaA
2cd80343ab fix aurora-1149 2026-07-02 19:07:58 +03:00
PotapovaA
c0e186baef Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-02 18:15:16 +03:00
PotapovaA
1df748fb03 fix-vsp rename fields 2026-07-02 18:13:55 +03:00
PotapovaA
2cc53b6846 fix-vsp rename fields 2026-07-02 18:10:35 +03:00
PotapovaA
944fbe4d7e fix-setting panel 2026-07-02 17:57:35 +03:00
155888f7fc Merge pull request 'front-projects' (#25) from front-projects into test
Reviewed-on: #25
2026-07-02 17:46:09 +03:00
PotapovaA
019b93092c fix-websocket 2026-07-02 17:45:00 +03:00
PotapovaA
a23a2dffc6 865 2026-07-02 17:31:23 +03:00
2e781ca809 projects-sheets: года в списке проектов, опциональный год при создании проекта 2026-07-02 16:44:45 +03:00
PotapovaA
cc62551c92 1134 2026-07-02 16:07:44 +03:00
PotapovaA
40c373b5ed projects 2026-07-02 15:35:44 +03:00
4ec4936a0f websocket-projects: возвращается line_id, ошибка при удалении строки 2026-07-02 14:51:40 +03:00
88a9713fb3 form3-permissions: фазы для 3 фрмы, правки фикстуры 2026-07-02 10:07:27 +03:00
PotapovaA
4e7cc9b3bd Merge branch 'test' into project-front 2026-07-01 11:29:30 +03:00
PotapovaA
c8cc1afec1 перенос constants.js в папку constants 2026-07-01 11:29:07 +03:00
047cabe14b Merge pull request 'Оказывается location_form это и есть placement_type, убрал location_form' (#21) from location_from_to_placement_type into test
Reviewed-on: #21
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-07-01 11:22:00 +03:00
7fabb22c45 Merge pull request 'smeta-fix: поправил ддл функии сметы первой формы, поправил фикстуры для теста' (#20) from smeta-fix into test
Reviewed-on: #20
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-30 17:25:41 +03:00
Raykov-MS
5d16915bad Оказывается location_form это и есть placement_type, убрал location_form 2026-06-30 17:24:08 +03:00
66846c6afa smeta-fix: поправил ддл функии сметы первой формы, поправил фикстуры для теста 2026-06-30 15:04:15 +03:00
b283bda007 Merge pull request 'rename vsp.number to vsp.staff_count' (#19) from fix-vsp-filters into test
Reviewed-on: #19
2026-06-30 12:37:56 +03:00
PotapovaA
4f8393bedc rename vsp.number to vsp.staff_count 2026-06-30 12:37:07 +03:00
796640a491 Merge pull request 'Убрал numbres...Э' (#18) from fix-tests-and-functions into test
Reviewed-on: #18
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-06-30 11:54:09 +03:00
Raykov-MS
03ea64d6b5 del numbers from schema 2026-06-30 11:52:39 +03:00
e91c5e1846 Merge pull request 'mig2-fix: поправил sql' (#17) from mig2-fix into test
Reviewed-on: #17
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-30 11:22:56 +03:00
Raykov-MS
8869423ea6 Убрал numbres...Э 2026-06-30 11:19:37 +03:00
c1059736fb mig2-fix: поправил sql 2026-06-30 11:14:23 +03:00
7a3ef892cf Merge pull request 'Поправил в бд функции' (#16) from fix-tests-and-functions into test
Reviewed-on: #16
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-06-30 11:04:27 +03:00
Raykov-MS
0c10be7eb1 del space 2026-06-30 10:56:23 +03:00
Raykov-MS
48c7ab3eb1 del print 2026-06-30 10:54:56 +03:00
Raykov-MS
20d0520b7d Поправил ошибки в тестах и функцию в бд 2026-06-30 10:54:09 +03:00
2e5cf3d2af Merge pull request 'vsp_fixes' (#15) from vsp_fixes into test
Reviewed-on: #15
2026-06-30 09:56:54 +03:00
Raykov-MS
3b7c93f5a7 del mig4 2026-06-30 09:51:17 +03:00
62a2f9bae4 Merge pull request 'fix-ssp-title: нельзя создать орг юнит с пустым названием' (#13) from fix-ssp-title into test
Reviewed-on: #13
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-30 09:34:32 +03:00
PotapovaA
e28f6a3887 список проектов 2026-06-29 19:22:41 +03:00
Raykov-MS
0652ba4aee 'alembic' 2026-06-29 17:29:35 +03:00
Raykov-MS
7da934399e Исправлена ошибка в схемах и логах 2026-06-29 17:17:28 +03:00
3c1722293c Merge pull request 'модалка для выбора всп' (#11) from AURORA-894 into test
Reviewed-on: #11
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-06-29 14:29:35 +03:00
905fa98ca5 Merge pull request '[AURORA-1148] - стили для таблицы' (#12) from AURORA-1148 into test
Reviewed-on: #12
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-06-29 11:48:28 +03:00
9c725e9678 fix-ssp-title: нельзя создать орг юнит с пустым названием 2026-06-29 11:37:23 +03:00
f26691805a [AURORA-1148] - стили для таблицы 2026-06-29 11:10:14 +03:00
2343b5c9b1 Merge pull request 'logs: логируем время' (#2) from logs into test
Reviewed-on: #2
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-29 11:01:45 +03:00
23c5f053bf Merge pull request 'sql-funcs2-fix: добавил правки по sql-функциям в смете' (#9) from sql-funcs2-fix into test
Reviewed-on: #9
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-29 11:01:25 +03:00
723a110035 Merge pull request 'fix-vsp-form-edit: не проверяем существование line_id, так как в листах сателлитах логика отличается' (#10) from fix-vsp-form-edit into test
Reviewed-on: #10
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-29 11:01:10 +03:00
5bfcf1302f fix-vsp-form-edit: не проверяем существование line_id, так как в листах сателлитах логика отличается 2026-06-26 16:21:02 +03:00
ee12939515 sql-funcs2-fix: добавил правки по sql-функциям в смете 2026-06-26 14:19:29 +03:00
bac2a7a780 logs: логируем время 2026-06-26 10:00:27 +03:00
137 changed files with 32526 additions and 6509 deletions

View File

@ -762,6 +762,43 @@ def upgrade() -> None:
['code'],
schema='v3',
)
create_table_if_not_exists(
'form3_phase',
sa.Column('rf_project_report_id', sa.Integer(),primary_key=True),
sa.Column('phase_code', sa.String(), primary_key=True),
sa.Column('role', sa.String(), nullable=False),
sa.Column('column_keys', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('opens_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('closes_at', sa.DateTime(timezone=True), nullable=False),
schema='v3',
)
create_check_constraint_if_not_exists(
'chk_v3_form3_phase_window', 'form3_phase',
sa.text('closes_at > opens_at'),
schema='v3',
)
create_check_constraint_if_not_exists(
'chk_v3_form3_phase_columns', 'form3_phase',
sa.text('cardinality(column_keys) > 0'),
schema='v3',
)
create_check_constraint_if_not_exists(
'chk_v3_form3_phase_no_admin', 'form3_phase',
sa.text("role != 'ADMIN'"),
schema='v3',
)
create_index_if_not_exists(
'ix_v3_form3_phase_active', 'form3_phase',
['rf_project_report_id', 'role', 'opens_at', 'closes_at'],
schema='v3',
)
create_index_if_not_exists(
'ix_v3_form3_phase_columns_gin', 'form3_phase',
['column_keys'],
schema='v3',
postgresql_using='gin',
)
create_table_if_not_exists(
'security_detail',
sa.Column('id', sa.Integer(),
@ -920,7 +957,6 @@ def upgrade() -> None:
sa.Column('vsp_type', sa.String(100)),
sa.Column('notes', sa.Text()),
sa.Column('location_form', sa.String()),
sa.Column('numbers', sa.Integer()),
sa.Column('rent_contract_num', sa.String()),
sa.Column('rent_end_date', sa.Date()),
schema='v3',

View File

@ -0,0 +1,63 @@
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.del_vsp(p_vsp_id integer, p_deleted_by integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_old RECORD;
v_usr RECORD;
BEGIN
SELECT vsp.id, vsp.branch_id
INTO v_old
FROM v3.vsp vsp WHERE vsp.id = p_vsp_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'vsp #% не существует', p_vsp_id;
END IF;
SELECT usr.id
INTO v_usr
FROM v3.app_user usr WHERE usr.id = p_deleted_by;
IF NOT FOUND THEN
RAISE EXCEPTION 'user #% не существует', p_deleted_by;
END IF;
UPDATE v3.vsp
SET is_deleted = true,
is_active = false,
updated_by = p_deleted_by
WHERE id = p_vsp_id;
UPDATE v3.vsp
SET closed_at = CURRENT_DATE
WHERE id = p_vsp_id
AND closed_at IS NULL;
PERFORM v3.log_event(
'DELETE_VSP', 'VSP',
jsonb_build_object(
'vsp_id', p_vsp_id,
'entity_id', p_vsp_id,
'branch_id', v_old.branch_id,
'updated_by', p_deleted_by
),
null, null, v_old.branch_id
);
RETURN p_vsp_id;
END;
$function$;
"""
)
def downgrade() -> None:
pass

View File

@ -0,0 +1,330 @@
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN opens_at TYPE timestamp USING opens_at::timestamp')
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN closes_at TYPE timestamp USING closes_at::timestamp')
op.execute("ALTER TABLE v3.org_unit ADD COLUMN utc_offset VARCHAR(3) DEFAULT '+03' NOT NULL")
# заводим фазу с нужной таймзоной
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.copy_template_to_form(p_form_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_inserted INTEGER;
v_utc_offset VARCHAR(3);
v_org_unit INTEGER;
BEGIN
SELECT form_type_code, org_unit_id INTO v_form_type, v_org_unit
FROM v3.budget_form
WHERE id = p_form_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit;
INSERT INTO v3.form_phase
(budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_form_id, pt.sheet, pt.phase_code, pt.role,
pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz,
(pt.closes_at::text || v_utc_offset)::timestamptz
FROM v3.phase_template pt
WHERE pt.form_type = v_form_type
ON CONFLICT (budget_form_id, sheet, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
;
"""
)
# Создавая фазу добавляем таймзону орг юнита
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp, p_closes_at timestamp)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_phase v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
BEGIN
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit_id;
INSERT INTO v3.form_phase(
budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at
)
VALUES (
p_budget_form_id,
p_sheet,
p_phase_code,
p_role,
p_column_keys,
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
)
RETURNING * INTO v_phase;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'form_phase',
'entity_id', p_budget_form_id,
'action', 'create',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'role', v_phase.role,
'column_keys', v_phase.column_keys,
'opens_at', v_phase.opens_at,
'closes_at', v_phase.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN v_phase;
END;
$function$
;
"""
)
# Обнволяя фазу добавляем таймзону орг юнита
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_new v3.form_phase;
v_is_extend BOOLEAN := FALSE;
v_has_non_extend_change BOOLEAN := FALSE;
v_has_window_change BOOLEAN := FALSE;
v_changes JSONB := '{}'::jsonb;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_opens_at timestamptz;
v_closes_at timestamptz;
BEGIN
SELECT * INTO v_old
FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code;
IF NOT FOUND THEN
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
p_budget_form_id, p_sheet, p_phase_code;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
IF p_opens_at is not null THEN
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
END IF;
IF p_closes_at is not null THEN
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
END IF;
UPDATE v3.form_phase
SET role = COALESCE(p_role, role),
column_keys = COALESCE(p_column_keys, column_keys),
opens_at = COALESCE(v_opens_at, opens_at),
closes_at = COALESCE(v_closes_at, closes_at)
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code
RETURNING * INTO v_new;
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
v_is_extend := v_new.closes_at > v_old.closes_at;
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
v_has_window_change := v_has_non_extend_change
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
IF v_old.role IS DISTINCT FROM v_new.role THEN
v_changes := v_changes || jsonb_build_object(
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
);
END IF;
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
v_changes := v_changes || jsonb_build_object(
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
);
END IF;
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
v_changes := v_changes || jsonb_build_object(
'opens_at', jsonb_build_object(
'before', v_old.opens_at,
'after', v_new.opens_at
)
);
END IF;
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
v_changes := v_changes || jsonb_build_object(
'closes_at', jsonb_build_object(
'before', v_old.closes_at,
'after', v_new.closes_at
)
);
END IF;
IF v_is_extend THEN
PERFORM v3.log_event(
'ACCESS_EXTEND', 'ACCESS',
jsonb_build_object(
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'closes_at_before', v_old.closes_at,
'closes_at_after', v_new.closes_at
),
p_budget_form_id
);
END IF;
IF v_has_window_change THEN
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'form_phase',
'entity_id', p_budget_form_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'changes', v_changes
),
p_budget_form_id, null, v_org_unit_id
);
END IF;
RETURN v_new;
END;
$function$
;
"""
)
# при обновлении таймзоны орг юнита обновляем актуальные фазы, связанные с ними
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz varchar(3), p_new_tz varchar(3))
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_result integer;
BEGIN
IF p_old_tz = p_new_tz THEN
RETURN 0;
END IF;
WITH updated AS (
UPDATE v3.form_phase
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id)
AND (
opens_at >= CURRENT_TIMESTAMP
OR closes_at >= CURRENT_TIMESTAMP
)
RETURNING *
)
SELECT COUNT(*) INTO v_result FROM updated;
RETURN v_result;
END;
$function$
;
"""
)
# op.add_column(
# 'org_unit',
# sa.Column(
# 'utc_offset',
# sa.String(3),
# nullable=False,
# server_default='+03',
# )
# )
def downgrade() -> None:
pass

View File

@ -0,0 +1,79 @@
import os
import re
from typing import List, Optional
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> Optional[str]:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> List[str]:
statements: List[str] = []
current: List[str] = []
in_dollar = False
dollar_tag: Optional[str] = None
for line in sql.split("\n"):
stripped = line.strip()
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0006_form3.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

@ -0,0 +1,180 @@
from alembic import op
import sqlalchemy as sa
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE OR REPLACE FUNCTION v3.v_form1_smeta(p_year integer, p_org_unit_id integer)
RETURNS TABLE(row_type character varying, depth integer, section_code character varying, name character varying, supp_plan_q1 numeric, supp_plan_q2 numeric, supp_plan_q3 numeric, supp_plan_q4 numeric, supp_plan_year numeric, dev_plan_q1 numeric, dev_plan_q2 numeric, dev_plan_q3 numeric, dev_plan_q4 numeric, dev_plan_year numeric, total_plan_year numeric, supp_appr_q1 numeric, supp_appr_q2 numeric, supp_appr_q3 numeric, supp_appr_q4 numeric, supp_appr_year numeric, dev_appr_q1 numeric, dev_appr_q2 numeric, dev_appr_q3 numeric, dev_appr_q4 numeric, dev_appr_year numeric, total_appr_year numeric, supp_act_q1 numeric, supp_act_q2 numeric, supp_act_q3 numeric, supp_act_q4 numeric, supp_act_year numeric, dev_act_q1 numeric, dev_act_q2 numeric, dev_act_q3 numeric, dev_act_q4 numeric, dev_act_year numeric, total_act_year numeric, supp_corr_q2 numeric, supp_corr_q3 numeric, supp_corr_q4 numeric, dev_corr_q2 numeric, dev_corr_q3 numeric, dev_corr_q4 numeric)
LANGUAGE plpgsql
STABLE
SET search_path TO 'v3', 'pg_catalog'
AS $function$
#variable_conflict use_column
DECLARE
v_ahr INT; v_cap INT; v_oper INT;
v_sections TEXT[] := ARRAY['plan','approved','q1','q2','q3','q4'];
BEGIN
-- Одна budget_form на лист содержит ОБА направления (budget_line.direction).
-- Support/Development различаются параметром p_direction, а не отдельной формой.
WITH ranked AS (
SELECT bf.id AS fid, ei.sheet AS sh
FROM budget_form bf
JOIN budget_line bl ON bl.budget_form_id = bf.id
JOIN expense_item ei ON ei.id = bl.expense_item_id
WHERE bf.year = p_year AND bf.org_unit_id = p_org_unit_id AND bf.form_type_code = 'FORM_1'
GROUP BY bf.id, ei.sheet
)
SELECT
MAX(CASE WHEN sh='AHR' THEN fid END),
MAX(CASE WHEN sh='CAP' THEN fid END),
MAX(CASE WHEN sh='OPER' THEN fid END)
INTO v_ahr, v_cap, v_oper
FROM ranked;
RETURN QUERY
WITH
ahr_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
ahr_d AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Development', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
cap_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
cap_d AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Development', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
oper_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_oper, 'OPER', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
merged AS (
SELECT COALESCE(s.row_type, d.row_type) AS row_type,
COALESCE(s.depth, d.depth) AS depth,
COALESCE(s.sc, d.sc) AS sc,
COALESCE(s.nm, d.nm) AS nm,
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
d.pq1 AS d_pq1, d.pq2 AS d_pq2, d.pq3 AS d_pq3, d.pq4 AS d_pq4, d.pyr AS d_pyr,
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
d.aq1 AS d_aq1, d.aq2 AS d_aq2, d.aq3 AS d_aq3, d.aq4 AS d_aq4, d.ayr AS d_ayr,
s.fq1, s.fq2, s.fq3, s.fq4,
d.fq1 AS d_fq1, d.fq2 AS d_fq2, d.fq3 AS d_fq3, d.fq4 AS d_fq4,
s.np2, s.np3, s.np4,
d.np2 AS d_np2, d.np3 AS d_np3, d.np4 AS d_np4
FROM ahr_s s FULL OUTER JOIN ahr_d d ON s.sc = d.sc
UNION ALL
SELECT COALESCE(s.row_type, d.row_type), COALESCE(s.depth, d.depth),
COALESCE(s.sc, d.sc), COALESCE(s.nm, d.nm),
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
d.pq1, d.pq2, d.pq3, d.pq4, d.pyr,
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
d.aq1, d.aq2, d.aq3, d.aq4, d.ayr,
s.fq1, s.fq2, s.fq3, s.fq4,
d.fq1, d.fq2, d.fq3, d.fq4,
s.np2, s.np3, s.np4,
d.np2, d.np3, d.np4
FROM cap_s s FULL OUTER JOIN cap_d d ON s.sc = d.sc
UNION ALL
SELECT s.row_type, s.depth, s.sc, s.nm,
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
s.fq1, s.fq2, s.fq3, s.fq4,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
s.np2, s.np3, s.np4,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC
FROM oper_s s
)
SELECT
m.row_type, m.depth, m.sc, m.nm,
m.pq1, m.pq2, m.pq3, m.pq4, m.pyr,
m.d_pq1, m.d_pq2, m.d_pq3, m.d_pq4, m.d_pyr,
COALESCE(m.pyr,0) + COALESCE(m.d_pyr,0) AS total_plan_year,
m.aq1, m.aq2, m.aq3, m.aq4, m.ayr,
m.d_aq1, m.d_aq2, m.d_aq3, m.d_aq4, m.d_ayr,
COALESCE(m.ayr,0) + COALESCE(m.d_ayr,0) AS total_appr_year,
m.fq1, m.fq2, m.fq3, m.fq4, COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0),
m.d_fq1, m.d_fq2, m.d_fq3, m.d_fq4, COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0),
COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0)
+ COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0),
m.np2, m.np3, m.np4,
m.d_np2, m.d_np3, m.d_np4
FROM merged m
ORDER BY m.sc;
END;
$function$
;
""")
for t_name in (
"reserve", "contract_detail", "ckk", "allocation", "sequestration", "contract_summary",
"collegial_approval", "rent_detail", "utility_detail", "plan", "budget_line_quarter"
):
op.execute(f"""
delete from v3.{t_name} where line_id in (
select id from v3.budget_line where budget_form_id not in (select min(fid) from (
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
FROM v3.budget_form bf
) ranked
group by yr, ftc, oui));
""")
op.execute("""
delete from v3.budget_line where budget_form_id not in (select min(fid) from (
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
FROM v3.budget_form bf
) ranked
group by yr, ftc, oui);
""")
op.execute("""
delete from v3.budget_form where id not in (select min(fid) from (
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
FROM v3.budget_form bf
) ranked
group by yr, ftc, oui);
""")
op.execute("ALTER TABLE v3.budget_form ADD CONSTRAINT uq_ftype_org_year UNIQUE (form_type_code, org_unit_id, year)")
def downgrade() -> None:
pass

View File

@ -0,0 +1,16 @@
from alembic import op
import sqlalchemy as sa
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("update v3.vsp set rent_end_date = coalesce(closed_at, opened_at) where rent_end_date is not null and opened_at is not null and rent_end_date < opened_at")
op.execute("alter table v3.vsp add CONSTRAINT chk_rent_date CHECK (((rent_end_date IS NULL) OR (opened_at IS NULL) OR (rent_end_date >= opened_at)))")
def downgrade() -> None:
pass

View File

@ -0,0 +1,785 @@
from alembic import op
import sqlalchemy as sa
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_report_type VARCHAR;
v_inserted INTEGER;
v_org_unit INTEGER;
v_utc_offset VARCHAR(3);
BEGIN
SELECT report_type, org_unit_id INTO v_report_type, v_org_unit
FROM v3.rf_project_report AS rpr
INNER JOIN v3.project AS p ON p.id = rpr.project_id
WHERE rpr.id = p_report_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit;
INSERT INTO v3.form3_phase
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_report_id, pt.phase_code, pt.role,
pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz,
(pt.closes_at::text || v_utc_offset)::timestamptz
FROM v3.phase_template pt
WHERE pt.form_type = 'FORM_3'
AND pt.sheet = v_report_type
ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.add_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone)
RETURNS v3.form3_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_phase v3.form3_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_report_type VARCHAR;
v_project_id INT;
BEGIN
SELECT p.org_unit_id, ou.title, rpr.year, ou.utc_offset, rpr.report_type, rpr.project_id
INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id
FROM v3.rf_project_report rpr
LEFT JOIN v3.project p ON p.id = rpr.project_id
LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id
WHERE rpr.id = p_rf_project_report_id;
INSERT INTO v3.form3_phase
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
VALUES (
p_rf_project_report_id,
p_phase_code,
p_role,
p_column_keys,
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
)
RETURNING * INTO v_phase;
v_task_name := trim(concat(
'FORM_3',
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'project',
'entity_id', v_project_id,
'core_entity_type', 'form3_phase',
'core_entity_id', v_phase_id,
'action', 'create',
'phase_id', v_phase_id,
'task_name', NULLIF(v_task_name, ''),
'report_id', p_rf_project_report_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'year', v_year,
'project_id', v_project_id,
'report_type', v_report_type,
'phase_code', p_phase_code,
'role', v_phase.role,
'column_keys', v_phase.column_keys,
'opens_at', v_phase.opens_at,
'closes_at', v_phase.closes_at
),
null, null, v_org_unit_id
);
RETURN v_phase;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
RETURNS v3.form3_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form3_phase;
v_new v3.form3_phase;
v_is_extend BOOLEAN := FALSE;
v_has_non_extend_change BOOLEAN := FALSE;
v_has_window_change BOOLEAN := FALSE;
v_changes JSONB := '{}'::jsonb;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_opens_at timestamptz;
v_closes_at timestamptz;
v_report_type VARCHAR;
v_project_id INT;
BEGIN
SELECT * INTO v_old
FROM v3.form3_phase
WHERE rf_project_report_id = p_rf_project_report_id
AND phase_code = p_phase_code;
IF NOT FOUND THEN
RAISE EXCEPTION 'form3_phase не найден (rf_project_report_id=%, phase=%)',
p_rf_project_report_id, p_phase_code;
END IF;
SELECT p.org_unit_id, ou.title, rpr.year, ou.utc_offset, rpr.report_type, rpr.project_id
INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id
FROM v3.rf_project_report rpr
LEFT JOIN v3.project p ON p.id = rpr.project_id
LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id
WHERE rpr.id = p_rf_project_report_id;
v_task_name := trim(concat(
'FORM_3',
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
IF p_opens_at is not null THEN
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
END IF;
IF p_closes_at is not null THEN
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
END IF;
UPDATE v3.form3_phase
SET role = COALESCE(p_role, role),
column_keys = COALESCE(p_column_keys, column_keys),
opens_at = COALESCE(v_opens_at, opens_at),
closes_at = COALESCE(v_closes_at, closes_at)
WHERE rf_project_report_id = p_rf_project_report_id
AND phase_code = p_phase_code
RETURNING * INTO v_new;
v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code);
v_is_extend := v_new.closes_at > v_old.closes_at;
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
v_has_window_change := v_has_non_extend_change
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
IF v_old.role IS DISTINCT FROM v_new.role THEN
v_changes := v_changes || jsonb_build_object(
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
);
END IF;
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
v_changes := v_changes || jsonb_build_object(
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
);
END IF;
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
v_changes := v_changes || jsonb_build_object(
'opens_at', jsonb_build_object(
'before', v_old.opens_at,
'after', v_new.opens_at
)
);
END IF;
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
v_changes := v_changes || jsonb_build_object(
'closes_at', jsonb_build_object(
'before', v_old.closes_at,
'after', v_new.closes_at
)
);
END IF;
IF v_is_extend THEN
PERFORM v3.log_event(
'ACCESS_EXTEND', 'ACCESS',
jsonb_build_object(
'entity_type', 'project',
'entity_id', v_project_id,
'core_entity_type', 'form3_phase',
'core_entity_id', v_phase_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_name', NULLIF(v_task_name, ''),
'report_id', p_rf_project_report_id,
'report_type', v_report_type,
'project_id', v_project_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'year', v_year,
'phase_code', p_phase_code,
'closes_at_before', v_old.closes_at,
'closes_at_after', v_new.closes_at
),
null, null, v_org_unit_id
);
END IF;
IF v_has_window_change THEN
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'project',
'entity_id', v_project_id,
'core_entity_type', 'form3_phase',
'core_entity_id', v_phase_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_rf_project_report_id,
'task_name', NULLIF(v_task_name, ''),
'project_id', v_project_id,
'report_type', v_report_type,
'year', v_year,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'phase_code', p_phase_code,
'changes', v_changes
),
null, null, v_org_unit_id
);
END IF;
RETURN v_new;
END;
$function$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.del_form3_phase(p_rf_project_report_id integer, p_phase_code character varying)
RETURNS boolean
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form3_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_report_type VARCHAR;
v_project_id INT;
BEGIN
DELETE FROM v3.form3_phase
WHERE rf_project_report_id = p_rf_project_report_id
AND phase_code = p_phase_code
RETURNING * INTO v_old;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
SELECT p.org_unit_id, ou.title, rpr.year, rpr.report_type, rpr.project_id
INTO v_org_unit_id, v_org_unit_title, v_year, v_report_type, v_project_id
FROM v3.rf_project_report rpr
LEFT JOIN v3.project p ON p.id = rpr.project_id
LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id
WHERE rpr.id = p_rf_project_report_id;
v_task_name := trim(concat(
'FORM_3',
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'project',
'entity_id', v_project_id,
'core_entity_type', 'form3_phase',
'core_entity_id', v_phase_id,
'action', 'delete',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_name', NULLIF(v_task_name, ''),
'project_id', v_project_id,
'year', v_year,
'report_type', v_report_type,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'phase_code', p_phase_code,
'role', v_old.role,
'column_keys', v_old.column_keys,
'opens_at', v_old.opens_at,
'closes_at', v_old.closes_at
),
null, null, v_org_unit_id
);
RETURN TRUE;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.add_project(p_name character varying, p_year integer, p_org_unit_id integer, p_level character varying DEFAULT 'project'::character varying, p_parent_id integer DEFAULT NULL::integer, p_project_type character varying DEFAULT NULL::character varying, p_vsp_format character varying DEFAULT NULL::character varying, p_placement_type character varying DEFAULT NULL::character varying, p_object_address character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_system_code character varying DEFAULT NULL::character varying)
RETURNS TABLE(project_id integer, limit_report_id integer, current_expenses_report_id integer)
LANGUAGE plpgsql
AS $function$
DECLARE
v_pid INT;
v_lim INT;
v_cur INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF p_org_unit_id IS NULL THEN
RAISE EXCEPTION 'p_org_unit_id обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN
RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id;
END IF;
IF p_parent_id IS NOT NULL AND
NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN
RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id;
END IF;
-- INSERT project (CHECK-ы name/project_type/vsp_format/placement_type
-- из migrate_form3_v1.sql применятся автоматически).
INSERT INTO v3.project (
name, level, parent_id, org_unit_id,
project_type, vsp_format, placement_type, object_address, staff_count, total_area,
system_code
) VALUES (
p_name, p_level, p_parent_id, p_org_unit_id,
p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area,
p_system_code
) RETURNING id INTO v_pid;
-- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей.
-- p_emit_audit=false событие года покрыто PROJECT_CREATE ниже. Дальнейшие
-- годы добавляются отдельно через v3.add_project_year.
SELECT y.limit_report_id, y.current_expenses_report_id
INTO v_lim, v_cur
FROM v3.add_project_year(v_pid, p_year, false) y;
-- Аудит: PROJECT_CREATE все заполненные поля + id отчётов
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'year', p_year,
'level', p_level,
'parent_id', p_parent_id,
'org_unit_id', p_org_unit_id,
'project_type', p_project_type,
'vsp_format', p_vsp_format,
'placement_type', p_placement_type,
'object_address', p_object_address,
'system_code', p_system_code,
'staff_count', p_staff_count,
'total_area', p_total_area,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
))
);
RETURN QUERY SELECT v_pid, v_lim, v_cur;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz character varying, p_new_tz character varying)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_result integer;
v_result3 integer;
BEGIN
IF p_old_tz = p_new_tz THEN
RETURN 0;
END IF;
WITH updated AS (
UPDATE v3.form_phase
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id)
AND (
opens_at >= CURRENT_TIMESTAMP
OR closes_at >= CURRENT_TIMESTAMP
)
RETURNING *
)
SELECT COUNT(*) INTO v_result FROM updated;
WITH updated AS (
UPDATE v3.form3_phase
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
WHERE rf_project_report_id IN (
SELECT id FROM V3.rf_project_report rpr WHERE project_id IN (
SELECT id FROM v3.project WHERE org_unit_id = p_org_unit_id
)
)
AND (
opens_at >= CURRENT_TIMESTAMP
OR closes_at >= CURRENT_TIMESTAMP
)
RETURNING *
)
SELECT COUNT(*) INTO v_result3 FROM updated;
RETURN v_result + v_result3;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_phase v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
BEGIN
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit_id;
INSERT INTO v3.form_phase(
budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at
)
VALUES (
p_budget_form_id,
p_sheet,
p_phase_code,
p_role,
p_column_keys,
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
)
RETURNING * INTO v_phase;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'action', 'create',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'role', v_phase.role,
'column_keys', v_phase.column_keys,
'opens_at', v_phase.opens_at,
'closes_at', v_phase.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN v_phase;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.del_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying)
RETURNS boolean
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
BEGIN
DELETE FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code
RETURNING * INTO v_old;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'action', 'delete',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'role', v_old.role,
'column_keys', v_old.column_keys,
'opens_at', v_old.opens_at,
'closes_at', v_old.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN TRUE;
END;
$function$
;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_new v3.form_phase;
v_is_extend BOOLEAN := FALSE;
v_has_non_extend_change BOOLEAN := FALSE;
v_has_window_change BOOLEAN := FALSE;
v_changes JSONB := '{}'::jsonb;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_opens_at timestamptz;
v_closes_at timestamptz;
BEGIN
SELECT * INTO v_old
FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code;
IF NOT FOUND THEN
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
p_budget_form_id, p_sheet, p_phase_code;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
IF p_opens_at is not null THEN
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
END IF;
IF p_closes_at is not null THEN
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
END IF;
UPDATE v3.form_phase
SET role = COALESCE(p_role, role),
column_keys = COALESCE(p_column_keys, column_keys),
opens_at = COALESCE(v_opens_at, opens_at),
closes_at = COALESCE(v_closes_at, closes_at)
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code
RETURNING * INTO v_new;
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
v_is_extend := v_new.closes_at > v_old.closes_at;
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
v_has_window_change := v_has_non_extend_change
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
IF v_old.role IS DISTINCT FROM v_new.role THEN
v_changes := v_changes || jsonb_build_object(
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
);
END IF;
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
v_changes := v_changes || jsonb_build_object(
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
);
END IF;
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
v_changes := v_changes || jsonb_build_object(
'opens_at', jsonb_build_object(
'before', v_old.opens_at,
'after', v_new.opens_at
)
);
END IF;
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
v_changes := v_changes || jsonb_build_object(
'closes_at', jsonb_build_object(
'before', v_old.closes_at,
'after', v_new.closes_at
)
);
END IF;
IF v_is_extend THEN
PERFORM v3.log_event(
'ACCESS_EXTEND', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'closes_at_before', v_old.closes_at,
'closes_at_after', v_new.closes_at
),
p_budget_form_id
);
END IF;
IF v_has_window_change THEN
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'task',
'entity_id', p_budget_form_id,
'core_entity_type', 'form_phase',
'core_entity_id', v_phase_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'changes', v_changes
),
p_budget_form_id, null, v_org_unit_id
);
END IF;
RETURN v_new;
END;
$function$
;
"""
)
def downgrade() -> None:
pass

View File

@ -0,0 +1,84 @@
import os
import re
import time
from alembic import op
import sqlalchemy as sa
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> str | None:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> list[str]:
statements: list[str] = []
current: list[str] = []
in_dollar = False
dollar_tag: str | None = None
for line in sql.split("\n"):
stripped = line.strip()
if stripped.startswith("--"):
continue
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0010_form4.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

View File

@ -1123,9 +1123,9 @@ END;
$function$
;
-- DROP FUNCTION v3.add_vsp(int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, int4, varchar, date);
-- DROP FUNCTION v3.add_vsp(int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, date);
CREATE OR REPLACE FUNCTION v3.add_vsp(p_branch_id integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT true, p_is_deleted boolean DEFAULT false, p_system_code character varying DEFAULT NULL::character varying, p_created_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_location_form character varying DEFAULT NULL::character varying, p_numbers integer DEFAULT NULL::integer, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date)
CREATE OR REPLACE FUNCTION v3.add_vsp(p_branch_id integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT true, p_is_deleted boolean DEFAULT false, p_system_code character varying DEFAULT NULL::character varying, p_created_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date)
RETURNS integer
LANGUAGE plpgsql
AS $function$
@ -1153,14 +1153,12 @@ BEGIN
(branch_id, reg_number, address, format, opened_at,
placement_type, staff_count, total_area, closed_at,
is_active, is_deleted, system_code, created_by,
vsp_type, notes, location_form, numbers,
rent_contract_num, rent_end_date)
vsp_type, notes, rent_contract_num, rent_end_date)
VALUES
(p_branch_id, p_reg_number, p_address, p_format, p_opened_at,
p_placement_type, p_staff_count, p_total_area, p_closed_at,
p_is_active, p_is_deleted, p_system_code, p_created_by,
p_vsp_type, p_notes, p_location_form, p_numbers,
p_rent_contract_num, p_rent_end_date)
p_vsp_type, p_notes, p_rent_contract_num, p_rent_end_date)
RETURNING id INTO v_id;
PERFORM v3.log_event(
@ -1184,8 +1182,6 @@ BEGIN
'created_by', p_created_by,
'vsp_type', p_vsp_type,
'notes', p_notes,
'location_form', p_location_form,
'numbers', p_numbers,
'rent_contract_num', p_rent_contract_num,
'rent_end_date', v3._diff_val(p_rent_end_date),
'user_email', v_actor_email,
@ -1574,7 +1570,7 @@ AS $function$
DECLARE
v_report_id INT;
v_eid INT;
v_org_id INT;
v_form_id INT;
v_ei_path INT[];
BEGIN
-- Извлекаем report_id + ei ДО DELETE (нужен для path)
@ -1588,21 +1584,33 @@ BEGIN
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
SELECT bf.id
INTO v_form_id
FROM v3.rf_project_report r
JOIN v3.project p
ON p.id = r.project_id
LEFT JOIN v3.budget_form bf
ON bf.form_type_code = 'FORM_3'
AND bf.year = r.year
AND bf.org_unit_id = p.org_unit_id
WHERE r.id = v_report_id
LIMIT 1;
-- Каскад (FK без ON DELETE CASCADE)
DELETE FROM v3.rf_project_report_quarter WHERE rf_project_report_line_id = p_line_id;
DELETE FROM v3.rf_project_report_line WHERE id = p_line_id;
SELECT org_unit_id INTO v_org_id FROM v3.project WHERE id = v_report_id;
-- Аудит: ROW_DELETE
PERFORM v3.log_event(
'ROW_DELETE', 'ROW',
jsonb_build_object(
'sheet', 'FORM_3',
'line_id', p_line_id,
'entity_id', p_line_id,
'form_id', v_form_id,
'report_id', v_report_id,
'expense_item_id', v_eid
), null, null, v_org_id
),
v_form_id
);
-- Возврат: только иерархия по пути удалённой (INPUT уже нет)
@ -2738,9 +2746,9 @@ END;
$function$
;
-- DROP FUNCTION v3.upd_vsp(int4, int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, int4, varchar, date);
-- DROP FUNCTION v3.upd_vsp(int4, int4, varchar, varchar, varchar, date, varchar, int4, numeric, date, bool, bool, varchar, int4, varchar, text, varchar, date);
CREATE OR REPLACE FUNCTION v3.upd_vsp(p_vsp_id integer, p_branch_id integer DEFAULT NULL::integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT NULL::boolean, p_is_deleted boolean DEFAULT NULL::boolean, p_system_code character varying DEFAULT NULL::character varying, p_updated_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_location_form character varying DEFAULT NULL::character varying, p_numbers integer DEFAULT NULL::integer, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date)
CREATE OR REPLACE FUNCTION v3.upd_vsp(p_vsp_id integer, p_branch_id integer DEFAULT NULL::integer, p_reg_number character varying DEFAULT NULL::character varying, p_address character varying DEFAULT NULL::character varying, p_format character varying DEFAULT NULL::character varying, p_opened_at date DEFAULT NULL::date, p_placement_type character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_closed_at date DEFAULT NULL::date, p_is_active boolean DEFAULT NULL::boolean, p_is_deleted boolean DEFAULT NULL::boolean, p_system_code character varying DEFAULT NULL::character varying, p_updated_by integer DEFAULT NULL::integer, p_vsp_type character varying DEFAULT NULL::character varying, p_notes text DEFAULT NULL::text, p_rent_contract_num character varying DEFAULT NULL::character varying, p_rent_end_date date DEFAULT NULL::date)
RETURNS void
LANGUAGE plpgsql
AS $function$
@ -2758,8 +2766,7 @@ BEGIN
SELECT v.branch_id, v.reg_number, v.address, v.format, v.opened_at,
v.placement_type, v.staff_count, v.total_area, v.closed_at,
v.is_active, v.is_deleted, v.system_code,
v.vsp_type, v.notes, v.location_form, v.numbers,
v.rent_contract_num, v.rent_end_date
v.vsp_type, v.notes, v.rent_contract_num, v.rent_end_date
INTO v_old
FROM v3.vsp v
WHERE v.id = p_vsp_id;
@ -2892,20 +2899,6 @@ BEGIN
'after', v3._diff_val(p_notes)));
END IF;
IF p_location_form IS NOT NULL AND p_location_form IS DISTINCT FROM v_old.location_form THEN
UPDATE v3.vsp SET location_form = p_location_form WHERE id = p_vsp_id;
v_changes := v_changes || jsonb_build_object('location_form',
jsonb_build_object('before', v3._diff_val(v_old.location_form),
'after', v3._diff_val(p_location_form)));
END IF;
IF p_numbers IS NOT NULL AND p_numbers IS DISTINCT FROM v_old.numbers THEN
UPDATE v3.vsp SET numbers = p_numbers WHERE id = p_vsp_id;
v_changes := v_changes || jsonb_build_object('numbers',
jsonb_build_object('before', v3._diff_val(v_old.numbers),
'after', v3._diff_val(p_numbers)));
END IF;
IF p_rent_contract_num IS NOT NULL AND p_rent_contract_num IS DISTINCT FROM v_old.rent_contract_num THEN
UPDATE v3.vsp SET rent_contract_num = p_rent_contract_num WHERE id = p_vsp_id;
v_changes := v_changes || jsonb_build_object('rent_contract_num',
@ -4093,15 +4086,13 @@ CREATE OR REPLACE FUNCTION v3.v_form1_smeta(p_year integer, p_org_unit_id intege
AS $function$
#variable_conflict use_column
DECLARE
v_ahr_s INT; v_ahr_d INT;
v_cap_s INT; v_cap_d INT;
v_oper_s INT; v_oper_d INT;
v_ahr INT; v_cap INT; v_oper INT;
v_sections TEXT[] := ARRAY['plan','approved','q1','q2','q3','q4'];
BEGIN
-- Resolve form_ids: within each (year, ssp, sheet), lower bf.id = Support, higher = Development
-- Одна budget_form на лист содержит ОБА направления (budget_line.direction).
-- Support/Development различаются параметром p_direction, а не отдельной формой.
WITH ranked AS (
SELECT bf.id AS fid, ei.sheet AS sh,
ROW_NUMBER() OVER (PARTITION BY ei.sheet ORDER BY bf.id) AS rn
SELECT bf.id AS fid, ei.sheet AS sh
FROM budget_form bf
JOIN budget_line bl ON bl.budget_form_id = bf.id
JOIN expense_item ei ON ei.id = bl.expense_item_id
@ -4109,18 +4100,14 @@ BEGIN
GROUP BY bf.id, ei.sheet
)
SELECT
MAX(CASE WHEN sh='AHR' AND rn=1 THEN fid END),
MAX(CASE WHEN sh='AHR' AND rn=2 THEN fid END),
MAX(CASE WHEN sh='CAP' AND rn=1 THEN fid END),
MAX(CASE WHEN sh='CAP' AND rn=2 THEN fid END),
MAX(CASE WHEN sh='OPER' AND rn=1 THEN fid END),
MAX(CASE WHEN sh='OPER' AND rn=2 THEN fid END)
INTO v_ahr_s, v_ahr_d, v_cap_s, v_cap_d, v_oper_s, v_oper_d
MAX(CASE WHEN sh='AHR' THEN fid END),
MAX(CASE WHEN sh='CAP' THEN fid END),
MAX(CASE WHEN sh='OPER' THEN fid END)
INTO v_ahr, v_cap, v_oper
FROM ranked;
RETURN QUERY
WITH
-- 5 источников: row_type, depth, section_code, name + 22 метрик из sections
ahr_s AS (
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
@ -4128,7 +4115,7 @@ BEGIN
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_ahr_s, 'AHR', 'Support', v_sections)
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
ahr_d AS (
@ -4138,7 +4125,7 @@ BEGIN
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_ahr_d, 'AHR', 'Development', v_sections)
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Development', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
cap_s AS (
@ -4148,7 +4135,7 @@ BEGIN
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_cap_s, 'CAP', 'Support', v_sections)
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
cap_d AS (
@ -4158,7 +4145,7 @@ BEGIN
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_cap_d, 'CAP', 'Development', v_sections)
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Development', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
oper_s AS (
@ -4168,12 +4155,10 @@ BEGIN
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
FROM v_form1_sheet_sections(v_oper_s, 'OPER', 'Support', v_sections)
FROM v_form1_sheet_sections(v_oper, 'OPER', 'Support', v_sections)
WHERE row_type IN ('ROOT','GROUP','ITEM')
),
-- Пивот по section_code
merged AS (
-- AHR блок
SELECT COALESCE(s.row_type, d.row_type) AS row_type,
COALESCE(s.depth, d.depth) AS depth,
COALESCE(s.sc, d.sc) AS sc,
@ -4188,7 +4173,6 @@ BEGIN
d.np2 AS d_np2, d.np3 AS d_np3, d.np4 AS d_np4
FROM ahr_s s FULL OUTER JOIN ahr_d d ON s.sc = d.sc
UNION ALL
-- CAP блок
SELECT COALESCE(s.row_type, d.row_type), COALESCE(s.depth, d.depth),
COALESCE(s.sc, d.sc), COALESCE(s.nm, d.nm),
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
@ -4201,7 +4185,6 @@ BEGIN
d.np2, d.np3, d.np4
FROM cap_s s FULL OUTER JOIN cap_d d ON s.sc = d.sc
UNION ALL
-- OPER блок (только Support)
SELECT s.row_type, s.depth, s.sc, s.nm,
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
@ -4232,7 +4215,6 @@ BEGIN
END;
$function$
;
-- DROP FUNCTION v3.v_form2_sheet_sections(int4, varchar, _text);
CREATE OR REPLACE FUNCTION v3.v_form2_sheet_sections(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[])
@ -4427,7 +4409,7 @@ BEGIN
LEFT JOIN v3.sequestration sg ON sg.line_id = bl.id AND sg.actor='SSP_GO' AND s_need_ap
LEFT JOIN v3.reserve r ON r.line_id = bl.id AND s_need_ap
LEFT JOIN v3.ckk ck ON ck.line_id = bl.id AND s_book
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet and (p_direction is null OR bl.direction is null or bl.direction = p_direction)
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet
AND (s_need_ap OR s_book)
GROUP BY bl.expense_item_id
),
@ -4453,7 +4435,7 @@ BEGIN
FROM v3.budget_line bl
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
JOIN v3.budget_line_quarter q ON q.line_id = bl.id
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet and (p_direction is null OR bl.direction is null or bl.direction = p_direction)
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet
GROUP BY bl.expense_item_id, q.quarter
),
-- Tree-rollup
@ -7791,3 +7773,406 @@ BEGIN
END;
$function$
;
CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id INT)
RETURNS INTEGER
AS $function$
DECLARE
v_report_type VARCHAR;
v_inserted INTEGER;
BEGIN
SELECT report_type INTO v_report_type
FROM v3.rf_project_report
WHERE id = p_report_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
INSERT INTO v3.form3_phase
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_report_id, pt.phase_code, pt.role,
pt.column_keys, pt.opens_at, pt.closes_at
FROM v3.phase_template pt
WHERE pt.form_type = 'FORM_3'
AND pt.sheet = v_report_type
ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
LANGUAGE plpgsql VOLATILE;
CREATE OR REPLACE FUNCTION v3.user_in_report_org(p_user_id INT, p_report_id INT)
RETURNS BOOLEAN
AS $function$
DECLARE
v_org INT;
BEGIN
SELECT p.org_unit_id
INTO v_org
FROM v3.rf_project_report r
JOIN v3.project p ON p.id = r.project_id
WHERE r.id = p_report_id;
IF v_org IS NULL THEN
RETURN TRUE; -- проект без org_unit — пропускаем фильтр
END IF;
RETURN EXISTS (
SELECT 1
FROM v3.user_org
WHERE user_id = p_user_id
AND org_unit_id = v_org
);
END;
$function$
LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION v3.editable_columns_for3(
p_report_id INT,
p_user_id INT
)
RETURNS TABLE (
column_key TEXT,
closes_at TIMESTAMPTZ
)
AS $function$
DECLARE
v_role VARCHAR;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
v_role := v3.user_role_code(p_user_id);
IF v_role IS NULL THEN
RETURN; -- неизвестный/неактивный юзер → пустая маска
END IF;
-- ADMIN: UNION всех column_keys всех фаз отчёта.
IF v_role = 'ADMIN' THEN
RETURN QUERY
SELECT ck AS column_key,
MAX(fp.closes_at) AS closes_at
FROM v3.form3_phase fp,
LATERAL unnest(fp.column_keys) AS ck
WHERE fp.rf_project_report_id = p_report_id
GROUP BY ck;
RETURN;
END IF;
-- EXECUTOR_RF без назначения → пустая маска (default deny).
IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN
RETURN;
END IF;
RETURN QUERY
SELECT ck AS column_key,
MAX(fp.closes_at) AS closes_at
FROM v3.form3_phase fp,
LATERAL unnest(fp.column_keys) AS ck
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND now() BETWEEN fp.opens_at AND fp.closes_at
GROUP BY ck;
END;
$function$
LANGUAGE plpgsql STABLE;
CREATE OR REPLACE FUNCTION v3.can_edit3(
p_report_id INT,
p_column_key TEXT,
p_user_id INT
)
RETURNS JSONB
AS $function$
DECLARE
v_role VARCHAR;
v_active_close TIMESTAMPTZ;
v_role_has_col BOOLEAN;
v_next_open TIMESTAMPTZ;
v_last_close TIMESTAMPTZ;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
END IF;
v_role := v3.user_role_code(p_user_id);
IF v_role IS NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'role_not_allowed',
'detail', 'unknown or inactive user'
);
END IF;
-- ADMIN — bypass всех проверок.
IF v_role = 'ADMIN' THEN
RETURN jsonb_build_object('ok', true, 'admin', true);
END IF;
-- EXECUTOR_RF: фильтр по user_org проекта отчёта.
IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'org_not_assigned',
'detail', p_column_key
);
END IF;
-- 1. Активная сейчас фаза, в которой эта колонка перечислена для этой роли?
SELECT MAX(fp.closes_at)
INTO v_active_close
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND now() BETWEEN fp.opens_at AND fp.closes_at;
IF v_active_close IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', true,
'closes_at', v_active_close
);
END IF;
-- 2. Колонка В ПРИНЦИПЕ есть хоть в одной фазе этой роли на этом отчёте?
SELECT EXISTS (
SELECT 1
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
) INTO v_role_has_col;
IF NOT v_role_has_col THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'role_not_allowed',
'detail', p_column_key
);
END IF;
-- 3. Колонка доступна, но окно не активно. Подсказать ближайшее.
SELECT MIN(fp.opens_at)
INTO v_next_open
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND fp.opens_at > now();
IF v_next_open IS NOT NULL THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'window_closed',
'detail', p_column_key,
'opens_at', v_next_open
);
END IF;
SELECT MAX(fp.closes_at)
INTO v_last_close
FROM v3.form3_phase fp
WHERE fp.rf_project_report_id = p_report_id
AND fp.role = v_role
AND p_column_key = ANY(fp.column_keys)
AND fp.closes_at <= now();
RETURN jsonb_build_object(
'ok', false,
'code', 'window_closed',
'detail', p_column_key,
'closes_at', v_last_close
);
END;
$function$
LANGUAGE plpgsql STABLE;
-- ─── 3. Authz-обёртки upd_form3_cell/cells (доп. сигнатуры с p_user_id) ─────
CREATE OR REPLACE FUNCTION v3.upd_form3_cells(
p_report_id INT,
p_changes JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_change JSONB;
v_column TEXT;
v_decision JSONB;
v_msg TEXT;
BEGIN
IF p_user_id IS NULL THEN
RAISE EXCEPTION 'role_not_allowed: <missing user_id>';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.app_user WHERE id = p_user_id AND is_active) THEN
RAISE EXCEPTION 'role_not_allowed: unknown or inactive user %', p_user_id;
END IF;
IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN
RAISE EXCEPTION 'p_changes must be a JSONB array';
END IF;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
v_column := v_change->>'column';
IF v_column IS NULL THEN
RAISE EXCEPTION 'change must have column: %', v_change;
END IF;
v_decision := v3.can_edit3(p_report_id, v_column, p_user_id);
IF NOT (v_decision->>'ok')::boolean THEN
IF v_decision->>'code' = 'role_not_allowed' THEN
RAISE EXCEPTION 'role_not_allowed: %', v_column;
ELSIF v_decision->>'code' = 'org_not_assigned' THEN
RAISE EXCEPTION 'org_not_assigned: %', v_column;
ELSIF v_decision->>'code' = 'window_closed' THEN
IF v_decision ? 'opens_at' THEN
v_msg := format('window_closed: %s (opens %s)',
v_column, v_decision->>'opens_at');
ELSIF v_decision->>'closes_at' IS NOT NULL THEN
v_msg := format('window_closed: %s (closed %s)',
v_column, v_decision->>'closes_at');
ELSE
v_msg := format('window_closed: %s (no phase)', v_column);
END IF;
RAISE EXCEPTION '%', v_msg;
ELSE
RAISE EXCEPTION '%: %', v_decision->>'code', v_column;
END IF;
END IF;
END LOOP;
RETURN QUERY
SELECT * FROM v3.upd_form3_cells(p_report_id, p_changes);
END;
$function$
LANGUAGE plpgsql VOLATILE;
CREATE OR REPLACE FUNCTION v3.upd_form3_cell(
p_report_id INT,
p_line_id INT,
p_column TEXT,
p_value JSONB,
p_user_id INT
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
SELECT * FROM v3.upd_form3_cells(
p_report_id,
jsonb_build_array(jsonb_build_object(
'line_id', p_line_id,
'column', p_column,
'value', p_value
)),
p_user_id
);
$function$
LANGUAGE sql VOLATILE;
-- ─── 4. add_project — раскладка snapshot обоих отчётов ──────────────────────
CREATE OR REPLACE FUNCTION v3.add_project(
p_name VARCHAR,
p_year INT,
p_org_unit_id INT,
p_level VARCHAR DEFAULT 'project',
p_parent_id INT DEFAULT NULL,
p_project_type VARCHAR DEFAULT NULL,
p_vsp_format VARCHAR DEFAULT NULL,
p_placement_type VARCHAR DEFAULT NULL,
p_object_address VARCHAR DEFAULT NULL,
p_staff_count INT DEFAULT NULL,
p_total_area NUMERIC DEFAULT NULL
)
RETURNS TABLE (
project_id INT,
limit_report_id INT,
current_expenses_report_id INT
)
AS $function$
DECLARE
v_pid INT;
v_lim INT;
v_cur INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF p_org_unit_id IS NULL THEN
RAISE EXCEPTION 'p_org_unit_id обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN
RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id;
END IF;
IF p_parent_id IS NOT NULL AND
NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN
RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id;
END IF;
INSERT INTO v3.project (
name, level, parent_id, org_unit_id,
project_type, vsp_format, placement_type, object_address, staff_count, total_area
) VALUES (
p_name, p_level, p_parent_id, p_org_unit_id,
p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area
) RETURNING id INTO v_pid;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (v_pid, p_year, 'LIMIT') RETURNING id INTO v_lim;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (v_pid, p_year, 'CURRENT_EXPENSES') RETURNING id INTO v_cur;
-- Snapshot RBAC-фаз (иначе default deny — отчёт read-only).
PERFORM v3.copy_template_to_report(v_lim);
PERFORM v3.copy_template_to_report(v_cur);
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT_CREATE',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'year', p_year,
'level', p_level,
'parent_id', p_parent_id,
'org_unit_id', p_org_unit_id,
'project_type', p_project_type,
'vsp_format', p_vsp_format,
'placement_type', p_placement_type,
'object_address', p_object_address,
'staff_count', p_staff_count,
'total_area', p_total_area,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
))
);
RETURN QUERY SELECT v_pid, v_lim, v_cur;
END;
$function$
LANGUAGE plpgsql VOLATILE;

View File

@ -4251,3 +4251,28 @@ INSERT INTO v3.expense_item_form_type (expense_item_id,form_type_code) VALUES
(798,'FORM_3');
REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree;
-- ─── 5. Seed фаз FORM_3 (идемпотентно, только FORM_3) ──────────────────────
-- DELETE скоупится на FORM_3 → фазы FORM_1/2/4 не затрагиваются.
DELETE FROM v3.phase_template WHERE form_type = 'FORM_3';
INSERT INTO v3.phase_template
(form_type, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT 'FORM_3', sh, 'RF_FILL_2026', 'EXECUTOR_RF',
ARRAY[
'q1.adj_by_items','q1.adj_increase','q1.m1','q1.m2','q1.m3',
'q2.adj_by_items','q2.adj_increase','q2.m1','q2.m2','q2.m3',
'q3.adj_by_items','q3.adj_increase','q3.m1','q3.m2','q3.m3',
'q4.adj_by_items','q4.adj_increase','q4.m1','q4.m2','q4.m3','q4.spod'
],
'2026-01-01 00:00:00+00'::timestamptz,
'2026-12-31 23:59:59+00'::timestamptz
FROM (VALUES ('LIMIT'), ('CURRENT_EXPENSES')) AS s(sh);
-- ─── 6. Backfill v3.form3_phase для существующих отчётов ────────────────────
-- ON CONFLICT DO NOTHING внутри copy_template_to_report → повторный прогон
-- ничего не дублирует.
SELECT id, v3.copy_template_to_report(id) AS phases_inserted
FROM v3.rf_project_report
ORDER BY id;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,852 @@
-- ════════════════════════════════════════════════════════════════════════════
-- migrate_form4_project.sql — выделение FORM_4 программ/проектов в отдельную
-- сущность v3.form4_project (перестают делить таблицу с FORM_3 v3.project).
-- ════════════════════════════════════════════════════════════════════════════
-- Мотивация: v3.project исторически был FORM_4-таблицей (program/project +
-- parent_id), FORM_3-редизайн навесил сверху свои атрибуты (project_type,
-- vsp_format, address, area, staff, org_unit_id) и строгий chk_v3_project_name.
-- Два разных концепта под одним словом «project» → разреженные колонки, нет
-- защиты FK, констрейнт вынужден ветвиться по «виду» строки. Разводим.
--
-- Новая таблица v3.form4_project — точный костяк без FORM_3-полей:
-- program (level='program', parent_id=NULL)
-- project (level='project', parent_id=program)
--
-- Дискриминатор FORM_4 vs FORM_3: FORM_3-строки — те, что ссылаются из
-- v3.rf_project_report (add_project всегда создаёт отчёты для КАЖДОЙ строки,
-- включая программы). Всё остальное в v3.project — FORM_4.
--
-- Порядок: после init.sql + всех migrate_form3*/migrate_to_identity (нужна
-- финальная форма v3.project с org_unit_id). Один прогон — copy защищён
-- проверкой на пустоту form4_project.
-- ════════════════════════════════════════════════════════════════════════════
-- ───── 1. Новая таблица ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS v3.form4_project (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
level VARCHAR CHECK (level IN ('program', 'project')),
section_code VARCHAR,
parent_id INTEGER REFERENCES v3.form4_project(id),
-- org_unit_id INTEGER REFERENCES v3.org_unit(id),
form_id INTEGER REFERENCES v3.budget_form(id),
-- project (level='project') обязан иметь родителя-программу; program — нет.
CONSTRAINT chk_form4_project_parent CHECK (
(level = 'program' AND parent_id IS NULL)
OR (level = 'project')
),
CONSTRAINT chk_form4_project_section_code CHECK (
(level = 'program') = (section_code IS NOT NULL)
)
);
CREATE INDEX ix_form4_project_section_code
ON v3.form4_project(section_code) WHERE level = 'program';
CREATE INDEX IF NOT EXISTS ix_v3_form4_project_parent ON v3.form4_project(parent_id);
CREATE INDEX IF NOT EXISTS ix_v3_form4_project_form ON v3.form4_project(form_id);
-- ───── 2. Перенос FORM_4-строк из v3.project (с сохранением id) ──────────────
-- FK budget_line.project_id ссылается по id → id сохраняем 1:1.
-- Копируем обе роли (program + project) одним INSERT: self-FK parent_id внутри
-- одного оператора проверяется в конце, порядок строк не важен.
-- DO $migrate$
-- BEGIN
-- IF NOT EXISTS (SELECT 1 FROM v3.form4_project) THEN
-- INSERT INTO v3.form4_project (id, name, level, parent_id, org_unit_id)
-- SELECT p.id, p.name, p.level, p.parent_id, (p.org_unit_id
-- FROM v3.project p
-- WHERE p.id NOT IN (
-- SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL
-- );
-- -- Синхронизируем sequence, чтобы новые INSERT не конфликтовали по id.
-- PERFORM setval('v3.form4_project_id_seq',
-- GREATEST((SELECT COALESCE(MAX(id), 0) FROM v3.form4_project), 1));
-- RAISE NOTICE 'form4_project: перенесено % строк из v3.project',
-- (SELECT count(*) FROM v3.form4_project);
-- ELSE
-- RAISE NOTICE 'form4_project уже наполнена — перенос пропущен';
-- END IF;
-- END
-- $migrate$;
-- ───── 3. Перецепка FK budget_line.project_id на новую таблицу ──────────────
ALTER TABLE v3.budget_line DROP CONSTRAINT IF EXISTS budget_line_project_id_fkey;
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_project_id_fkey FOREIGN KEY (project_id) REFERENCES v3.form4_project(id);
-- ───── 4. Удаление перенесённых строк из v3.project ─────────────────────────
-- После перецепки budget_line на v3.project больше не ссылается ничего FORM_4.
-- Остаются только FORM_3-строки (referenced by rf_project_report).
DELETE FROM v3.project p
WHERE p.id NOT IN (
SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL
);
-- ───── 5. Восстановление chk_v3_project_name в строгой форме ─────────────────
-- FORM_4-строки ушли — v3.project теперь чисто FORM_3, правило коротких имён
-- ВСП применимо ко всей таблице (как до form4_write.sql). NOT VALID сохраняем.
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS chk_v3_project_name;
ALTER TABLE v3.project
ADD CONSTRAINT chk_v3_project_name
CHECK (name IS NULL OR (
length(name) <= 30 AND
name !~ E'[+\\-/\\\\=&* ]'
)) NOT VALID;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.add_form4_program / v3.add_form4_project — создание программ и проектов
-- FORM_4 в выделенной таблице v3.form4_project (иерархия program → project).
-- ════════════════════════════════════════════════════════════════════════════
-- FORM_4 хранит иерархию отдельно от FORM_3 (v3.project) — см.
-- migrate_form4_project.sql. Костяк:
-- program (level='program', parent_id=NULL) → depth=1 (GROUP) в v_form4
-- project (level='project', parent_id=program) → depth=2 (ITEM) в v_form4
-- Строки бюджета привязываются к проекту через budget_line.project_id.
--
-- Никаких FORM_3-атрибутов / rf_project_report / snapshot фаз — это ветка
-- budget_form. p_org_unit_id опционален (read-path FORM_4 по нему не фильтрует,
-- заполняется для аудита/принадлежности).
--
-- Порядок применения: после migrate_form4_project.sql (нужна таблица
-- v3.form4_project) и audit.sql (нужна v3.log_event).
-- ════════════════════════════════════════════════════════════════════════════
-- ───── 1. add_form4_program ─────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.add_form4_program(VARCHAR, INT, VARCHAR, INT);
CREATE FUNCTION v3.add_form4_program(
p_name VARCHAR,
p_form_id INT,
p_section_code VARCHAR DEFAULT NULL
)
RETURNS TABLE (
project_id INT,
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
) AS $function$
#variable_conflict use_column
DECLARE
v_pid INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_form_id IS NULL THEN
RAISE EXCEPTION 'p_form_id обязателен';
END IF;
IF NOT EXISTS (
SELECT 1 FROM v3.budget_form bf
WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id
) THEN
RAISE EXCEPTION 'Только FORM_4';
END IF;
IF p_section_code IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM v3.expense_item
WHERE section_code = p_section_code AND depth = 0
) THEN
RAISE EXCEPTION 'section_code=% не существует в expense_item (depth=0)', p_section_code;
END IF;
INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code)
VALUES (p_name, 'program', NULL, p_form_id, p_section_code)
RETURNING id INTO v_pid;
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'level', 'program',
'form_id', p_form_id,
'form_type', 'FORM_4'
)),
p_form_id, NULL, NULL
);
RETURN QUERY
SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form4_sheet_jsonb(p_form_id, p_section_code, NULL) v
WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_pid)
OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = p_section_code)
ORDER BY v.sort_order;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ───── 2. add_form4_project ─────────────────────────────────────────────────
DROP FUNCTION IF EXISTS v3.add_form4_project(VARCHAR, INT);
CREATE FUNCTION v3.add_form4_project(
p_name VARCHAR,
p_program_id INT
)
RETURNS TABLE (
project_id INT,
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
#variable_conflict use_column
DECLARE
v_pid INT;
v_section_code VARCHAR;
v_form_id INT;
BEGIN
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'p_name обязателен';
END IF;
IF p_program_id IS NULL THEN
RAISE EXCEPTION 'p_program_id обязателен (проект создаётся под программой)';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp
WHERE fp.id = p_program_id AND fp.level = 'program') THEN
RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id;
END IF;
SELECT form_id INTO v_form_id FROM v3.form4_project WHERE id = p_program_id;
INSERT INTO v3.form4_project (name, level, parent_id, form_id)
VALUES (p_name, 'project', p_program_id, v_form_id)
RETURNING id INTO v_pid;
PERFORM v3.log_event(
'PROJECT_CREATE', 'PROJECT',
jsonb_strip_nulls(jsonb_build_object(
'project_id', v_pid,
'name', p_name,
'level', 'project',
'parent_id', p_program_id,
'form_id', v_form_id,
'form_type', 'FORM_4'
)),
v_form_id, NULL, NULL
);
SELECT fp.section_code INTO v_section_code
FROM v3.form4_project fp
WHERE fp.id = p_program_id AND fp.level = 'program';
IF v_section_code IS NOT NULL THEN
RETURN QUERY
SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v
WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid)
OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id)
OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code)
ORDER BY v.sort_order;
END IF;
END;
$function$
LANGUAGE plpgsql VOLATILE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.v_form4_sheet_jsonb — JSONB-выдача FORM_4 для АХР_Р / КВ_Р / Операц
-- ════════════════════════════════════════════════════════════════════════════
-- Иерархия (4 уровня):
-- ROOT depth=0 — section_code (1.00.0/3.00.0/4.00.0/5.00.0)
-- PROGRAM depth=1 — v3.form4_project level=program
-- PROJECT depth=2 — v3.form4_project level=project (parent_id=program)
-- INPUT depth=3 — budget_line (R-код)
--
-- Финансовые блоки (plan, seq_dfip, approved, booking, q1-q4, totals)
-- агрегируются на уровнях ROOT/PROGRAM/PROJECT через v3.jsonb_numeric_sum.
-- Текстовые поля (counterparty, comment, justification…) на агрегатных
-- уровнях NULL'ятся (jsonb_numeric_sum их игнорирует).
--
-- p_form_id — id формы FORM_4
-- p_sheet — 'AHR' / 'CAP' / 'OPER'
-- p_sections — массив включаемых блоков (как в v_form4_sheet_lines_jsonb)
-- ════════════════════════════════════════════════════════════════════════════
-- ───── 1. Хелпер: рекурсивная сумма численных листьев двух JSONB ──────────
CREATE OR REPLACE FUNCTION v3.jsonb_numeric_sum(a JSONB, b JSONB)
RETURNS JSONB LANGUAGE sql IMMUTABLE AS $function$
SELECT CASE
WHEN a IS NULL THEN b
WHEN b IS NULL THEN a
WHEN jsonb_typeof(a) = 'number' AND jsonb_typeof(b) = 'number' THEN
to_jsonb((a)::text::numeric + (b)::text::numeric)
WHEN jsonb_typeof(a) = 'object' AND jsonb_typeof(b) = 'object' THEN (
SELECT jsonb_object_agg(k,
v3.jsonb_numeric_sum(a -> k, b -> k)
)
FROM (
SELECT k FROM jsonb_object_keys(a) k
UNION
SELECT k FROM jsonb_object_keys(b) k
) keys
)
-- одна сторона число/объект, другая нет → берём числовую/объектную
WHEN jsonb_typeof(a) IN ('number','object') THEN a
WHEN jsonb_typeof(b) IN ('number','object') THEN b
ELSE NULL
END;
$function$;
DROP AGGREGATE IF EXISTS v3.jsonb_sum(JSONB);
CREATE AGGREGATE v3.jsonb_sum(JSONB) (
SFUNC = v3.jsonb_numeric_sum,
STYPE = JSONB
);
-- ───── 2. Основная функция ─────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb(
p_form_id INT,
p_sheet VARCHAR,
p_sections TEXT[] DEFAULT NULL
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
-- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════
WITH inp_raw AS (
SELECT j.*
FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j
WHERE j.row_type = 'INPUT'
),
inp AS (
SELECT
i.*,
bl.project_id AS line_project_id,
-- --- разрешение проекта ---
-- A. bl.project_id → project (level='project')
-- B. bl.project_id → program (level='program') — тогда проекта нет
CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id,
CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name,
-- --- разрешение программы ---
-- A. через проект: prog_via_prj
-- B. напрямую: prog_direct
COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id,
COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name,
-- --- section (depth=0) ---
sec.id AS sec_id,
sec.section_code AS sec_code,
sec.name AS sec_name
FROM inp_raw i
JOIN v3.budget_line bl ON bl.id = i.line_id
-- проект (level='project')
LEFT JOIN v3.form4_project prj
ON prj.id = bl.project_id AND prj.level = 'project'
-- программа через проект
LEFT JOIN v3.form4_project prog_via_prj
ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program'
-- программа напрямую (bl.project_id → program)
LEFT JOIN v3.form4_project prog_direct
ON prog_direct.id = bl.project_id AND prog_direct.level = 'program'
-- раздел depth=0 (через expense_item)
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN LATERAL (
WITH RECURSIVE up AS (
SELECT id, parent_id, name, section_code, depth
FROM v3.expense_item WHERE id = ei.id
UNION ALL
SELECT e.id, e.parent_id, e.name, e.section_code, e.depth
FROM v3.expense_item e JOIN up u ON u.parent_id = e.id
)
SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1
) sec ON TRUE
),
-- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════
all_programs AS (
SELECT
fp.id AS prog_id,
fp.name AS prog_name,
fp.section_code,
sec.id AS sec_id,
sec.section_code AS sec_code,
sec.name AS sec_name
FROM v3.form4_project fp
LEFT JOIN LATERAL (
SELECT id, section_code, name
FROM v3.expense_item
WHERE section_code = fp.section_code AND depth = 0
LIMIT 1
) sec ON TRUE
WHERE fp.level = 'program'
AND (p_sections IS NULL OR fp.section_code = ANY(p_sections))
AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id
),
-- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════
all_projects AS (
SELECT
prj.id AS prj_id,
prj.name AS prj_name,
prj.parent_id AS prog_id,
ap.sec_id, ap.sec_code, ap.sec_name
FROM v3.form4_project prj
JOIN all_programs ap ON ap.prog_id = prj.parent_id
WHERE prj.level = 'project' AND (ap.section_code = p_sheet)
),
-- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════
-- PROJECT-агрегаты
prj_agg AS (
SELECT
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
prog_id, MAX(prog_name) AS prog_name,
prj_id, MAX(prj_name) AS prj_name,
v3.jsonb_sum(plan_data) AS plan_data,
v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data,
v3.jsonb_sum(approved_data) AS approved_data,
v3.jsonb_sum(contract_summary_data) AS contract_summary_data,
v3.jsonb_sum(allocation_data) AS allocation_data,
v3.jsonb_sum(reserve_data) AS reserve_data,
v3.jsonb_sum(collegial_data) AS collegial_data,
v3.jsonb_sum(ckk_data) AS ckk_data,
v3.jsonb_sum(contract_data) AS contract_data,
v3.jsonb_sum(booking_data) AS booking_data,
v3.jsonb_sum(q1_data) AS q1_data,
v3.jsonb_sum(q2_data) AS q2_data,
v3.jsonb_sum(q3_data) AS q3_data,
v3.jsonb_sum(q4_data) AS q4_data,
v3.jsonb_sum(totals_data) AS totals_data
FROM inp WHERE prj_id IS NOT NULL
GROUP BY sec_id, prog_id, prj_id
),
-- PROGRAM-агрегаты
prog_agg AS (
SELECT
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
prog_id, MAX(prog_name) AS prog_name,
v3.jsonb_sum(plan_data) AS plan_data,
v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data,
v3.jsonb_sum(approved_data) AS approved_data,
v3.jsonb_sum(contract_summary_data) AS contract_summary_data,
v3.jsonb_sum(allocation_data) AS allocation_data,
v3.jsonb_sum(reserve_data) AS reserve_data,
v3.jsonb_sum(collegial_data) AS collegial_data,
v3.jsonb_sum(ckk_data) AS ckk_data,
v3.jsonb_sum(contract_data) AS contract_data,
v3.jsonb_sum(booking_data) AS booking_data,
v3.jsonb_sum(q1_data) AS q1_data,
v3.jsonb_sum(q2_data) AS q2_data,
v3.jsonb_sum(q3_data) AS q3_data,
v3.jsonb_sum(q4_data) AS q4_data,
v3.jsonb_sum(totals_data) AS totals_data
FROM inp WHERE prog_id IS NOT NULL
GROUP BY sec_id, prog_id
),
-- ROOT-агрегаты
root_agg AS (
SELECT
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
v3.jsonb_sum(plan_data) AS plan_data,
v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data,
v3.jsonb_sum(approved_data) AS approved_data,
v3.jsonb_sum(contract_summary_data) AS contract_summary_data,
v3.jsonb_sum(allocation_data) AS allocation_data,
v3.jsonb_sum(reserve_data) AS reserve_data,
v3.jsonb_sum(collegial_data) AS collegial_data,
v3.jsonb_sum(ckk_data) AS ckk_data,
v3.jsonb_sum(contract_data) AS contract_data,
v3.jsonb_sum(booking_data) AS booking_data,
v3.jsonb_sum(q1_data) AS q1_data,
v3.jsonb_sum(q2_data) AS q2_data,
v3.jsonb_sum(q3_data) AS q3_data,
v3.jsonb_sum(q4_data) AS q4_data,
v3.jsonb_sum(totals_data) AS totals_data
FROM inp
GROUP BY sec_id
),
-- Нулевой JSONB-шаблон (для пустых программ/проектов)
zero AS (
SELECT '{}'::jsonb AS z
),
-- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════
unioned AS (
-- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк)
SELECT
'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id,
jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header,
plan_data, seq_dfip_data, approved_data,
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
contract_data, booking_data,
q1_data, q2_data, q3_data, q4_data, totals_data,
ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path
FROM root_agg
UNION ALL
-- GROUP — из агрегатов (программы с бюджетными строками)
SELECT
'GROUP', 1, NULL,
jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id),
plan_data, seq_dfip_data, approved_data,
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
contract_data, booking_data,
q1_data, q2_data, q3_data, q4_data, totals_data,
ARRAY[sec_id, prog_id, 0, 0]::INT[]
FROM prog_agg
UNION ALL
-- GROUP — пустые программы (нет ни одной budget_line)
SELECT
'GROUP', 1, NULL,
jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id),
NULL::jsonb, NULL::jsonb, NULL::jsonb, -- plan, seq_dfip, approved
NULL::jsonb, NULL::jsonb, NULL::jsonb, -- contract_summary, allocation, reserve
NULL::jsonb, NULL::jsonb, -- collegial, ckk
NULL::jsonb, NULL::jsonb, -- contract, booking
NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, -- q1-q4, totals
ARRAY[sec_id, prog_id, 0, 0]::INT[]
FROM all_programs ap
WHERE NOT EXISTS (
SELECT 1 FROM v3.budget_line bl
WHERE bl.budget_form_id = p_form_id
AND (
bl.project_id = ap.prog_id -- прямая привязка к программе
OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект
WHERE pr.parent_id = ap.prog_id AND pr.level = 'project')
)
)
UNION ALL
-- ITEM — из агрегатов (проекты с бюджетными строками)
SELECT
'ITEM', 2, NULL,
jsonb_build_object('section_code', sec_code, 'name', prj_name,
'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id),
plan_data, seq_dfip_data, approved_data,
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
contract_data, booking_data,
q1_data, q2_data, q3_data, q4_data, totals_data,
ARRAY[sec_id, prog_id, prj_id, 0]::INT[]
FROM prj_agg
UNION ALL
-- ITEM — пустые проекты (нет budget_line)
SELECT
'ITEM', 2, NULL,
jsonb_build_object('section_code', sec_code, 'name', prj_name,
'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id),
NULL::jsonb, NULL::jsonb, NULL::jsonb,
NULL::jsonb, NULL::jsonb, NULL::jsonb,
NULL::jsonb, NULL::jsonb,
NULL::jsonb, NULL::jsonb,
NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb,
ARRAY[sec_id, prog_id, prj_id, 0]::INT[]
FROM all_projects apr
WHERE NOT EXISTS (
SELECT 1 FROM v3.budget_line bl
WHERE bl.budget_form_id = p_form_id
AND bl.project_id = apr.prj_id
)
UNION ALL
-- INPUT — все строки бюджета
SELECT
'INPUT', 3, line_id,
header || jsonb_build_object(
'project_id', prj_id, 'project_name', prj_name,
'program_id', prog_id, 'program_name', prog_name
),
plan_data, seq_dfip_data, approved_data,
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
contract_data, booking_data,
q1_data, q2_data, q3_data, q4_data, totals_data,
ARRAY[sec_id, COALESCE(prog_id, 999999), COALESCE(prj_id, 999999), line_id]::INT[]
FROM inp
)
SELECT
u.row_type, u.depth,
ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order,
jsonb_build_object(
'line_id', u.line_id,
'header', u.header,
'plan', u.plan_data,
'seq_dfip', u.seq_dfip_data,
'approved', u.approved_data,
'contract_summary', u.contract_summary_data,
'allocation', u.allocation_data,
'reserve', u.reserve_data,
'collegial', u.collegial_data,
'ckk', u.ckk_data,
'contract', u.contract_data,
'booking', u.booking_data,
'q1', u.q1_data,
'q2', u.q2_data,
'q3', u.q3_data,
'q4', u.q4_data,
'totals', u.totals_data
) AS data
FROM unioned u
ORDER BY u.sort_path;
$function$
LANGUAGE sql STABLE;
-- ════════════════════════════════════════════════════════════════════════════
-- v3.add_budget_line — создать новую INPUT-строку (budget_line) для FORM_1/2/4.
--
-- Способы передать expense_item:
-- путь A — численный id: p_expense_item_id (берётся из header.expense_item_id)
-- путь B — резолв по коду: p_sheet + p_item_id [+ p_section_code для коллизий]
-- [+ p_direction для FORM_1]
--
-- Авто-direction:
-- FORM_2 → 'Support'
-- FORM_4 → 'Development'
-- FORM_1/OPER → NULL
-- FORM_1/AHR|CAP → p_direction обязателен (Support|Development)
-- Если p_direction передан и противоречит авто-значению — ошибка.
--
-- Дубликаты на одном expense_item разрешены (UNIQUE не накладывается).
-- vsp_id опционален — для FORM_2 фронт может не знать ВСП на момент
-- создания, дозаполнит позже через upd_form_cell(.., 'header.vsp_id', ..).
--
-- FORM_3 не поддерживается — там строки живут в rf_project_report_line,
-- API будет в add_form3_line.sql (отдельная итерация).
--
-- Возврат: вся иерархия (ROOT/GROUP/ITEM/SUB_ITEM/SECTION/LEAF) + новая
-- INPUT (отфильтрована по новому line_id). Новый id извлекается из
-- (data->>'line_id')::INT первой INPUT-строки.
-- ════════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR);
DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR, VARCHAR, DATE);
CREATE FUNCTION v3.add_budget_line(
p_form_id INT,
p_expense_item_id INT DEFAULT NULL,
p_sheet VARCHAR DEFAULT NULL,
p_item_id VARCHAR DEFAULT NULL,
p_section_code VARCHAR DEFAULT NULL,
p_direction VARCHAR DEFAULT NULL,
p_name VARCHAR DEFAULT NULL,
p_internal_order VARCHAR DEFAULT NULL,
p_vsp_id INT DEFAULT NULL,
p_project_id INT DEFAULT NULL,
p_justification VARCHAR DEFAULT NULL,
-- сателлитные листы (AHR_RENT/UTILITY/SECURITY) используют дополнительно:
p_contract_number VARCHAR DEFAULT NULL,
p_contract_end_date DATE DEFAULT NULL
)
RETURNS TABLE (
row_type VARCHAR,
depth INT,
sort_order BIGINT,
data JSONB
)
AS $function$
DECLARE
v_form_type VARCHAR;
v_eid INT;
v_sheet VARCHAR;
v_auto_dir VARCHAR;
v_final_dir VARCHAR;
v_new_id INT;
v_cnt INT;
v_sat_table TEXT;
v_sat_eid INT;
v_parent_line INT;
BEGIN
-- 1. form_id → form_type
SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id;
IF v_form_type IS NULL THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
IF v_form_type = 'FORM_3' THEN
RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)';
END IF;
-- ═══ Сателлитные листы (AHR_RENT/UTILITY/SECURITY) ═══════════════════
-- Фиксированный expense_item_id на лист — UI не передаёт R-код.
-- p_vsp_id обязателен. Опц.: p_contract_number, p_contract_end_date.
-- Внутри: находим/создаём родительскую budget_line с фиксированным ei,
-- затем INSERT в *_detail. Возврат — одна новая LEAF.
IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN
v_sat_table := CASE p_sheet
WHEN 'AHR_RENT' THEN 'rent_detail'
WHEN 'AHR_UTILITY' THEN 'utility_detail'
WHEN 'AHR_SECURITY' THEN 'security_detail'
END;
v_sat_eid := CASE p_sheet
WHEN 'AHR_RENT' THEN 293 -- R061031001 "Аренда_ОН"
WHEN 'AHR_UTILITY' THEN 24 -- 1.05.1. "Коммунальные услуги"
WHEN 'AHR_SECURITY' THEN 779 -- R061062001 "Пультовая охр.ОН"
END;
IF p_vsp_id IS NULL THEN
RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet;
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN
RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id;
END IF;
-- Находим/создаём родительскую budget_line под фиксированным ei
SELECT id INTO v_parent_line
FROM v3.budget_line
WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid
LIMIT 1;
IF v_parent_line IS NULL THEN
INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction)
VALUES (
p_form_id, v_sat_eid,
CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END
) RETURNING id INTO v_parent_line;
END IF;
-- INSERT в сателлит
EXECUTE format(
'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) '
|| 'VALUES ($1, $2, $3, $4) RETURNING id',
v_sat_table
) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date
INTO v_new_id;
-- Возврат — одна новая INPUT-строка (отфильтрована по новому id сателлита)
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v
WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id;
RETURN;
END IF;
-- 2. Резолв expense_item_id
IF p_expense_item_id IS NOT NULL THEN
SELECT ei.id, ei.sheet INTO v_eid, v_sheet
FROM v3.expense_item ei WHERE ei.id = p_expense_item_id;
IF v_eid IS NULL THEN
RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM v3.expense_item_form_type
WHERE expense_item_id = v_eid AND form_type_code = v_form_type
) THEN
RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type;
END IF;
ELSE
IF p_sheet IS NULL OR p_item_id IS NULL THEN
RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)';
END IF;
SELECT count(*) INTO v_cnt
FROM v3.expense_item ei
JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id
WHERE ei.item_id = p_item_id
AND ei.sheet = p_sheet
AND eift.form_type_code = v_form_type
AND (p_section_code IS NULL OR ei.section_code = p_section_code)
AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction);
IF v_cnt = 0 THEN
RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%',
p_sheet, p_item_id, p_section_code, p_direction, v_form_type;
END IF;
IF v_cnt > 1 THEN
RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую',
v_cnt, p_sheet, p_item_id, p_section_code, p_direction;
END IF;
SELECT ei.id, ei.sheet INTO v_eid, v_sheet
FROM v3.expense_item ei
JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id
WHERE ei.item_id = p_item_id
AND ei.sheet = p_sheet
AND eift.form_type_code = v_form_type
AND (p_section_code IS NULL OR ei.section_code = p_section_code)
AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction);
END IF;
-- 3. Авто-direction (для FORM_1/AHR|CAP — обязателен у юзера)
v_auto_dir := CASE
WHEN v_form_type = 'FORM_2' THEN 'Support'
WHEN v_form_type = 'FORM_4' THEN 'Development'
WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL
ELSE NULL
END;
IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN
RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet;
END IF;
IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN
RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)',
p_direction, v_form_type, v_auto_dir;
END IF;
IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN
RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction;
END IF;
v_final_dir := COALESCE(p_direction, v_auto_dir);
-- 4. Sanity на vsp/project
IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN
RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id;
END IF;
IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'form4_project id=% не существует', p_project_id;
END IF;
-- 5. INSERT
INSERT INTO v3.budget_line (
budget_form_id, expense_item_id, name, internal_order,
vsp_id, project_id, direction, justification
) VALUES (
p_form_id, v_eid, p_name, p_internal_order,
p_vsp_id, p_project_id, v_final_dir, p_justification
) RETURNING id INTO v_new_id;
-- 5а. Аудит: ROW_CREATE
PERFORM v3.log_event(
'ROW_CREATE', 'ROW_CREATE',
jsonb_build_object(
'sheet', v_sheet,
'line_id', v_new_id,
'expense_item_id', v_eid,
'direction', v_final_dir,
'name', p_name,
'vsp_id', p_vsp_id,
'project_id', p_project_id
),
p_form_id
);
-- 6. Возврат: только иерархия по пути нового ei + новая INPUT
DECLARE v_ei_path INT[];
BEGIN
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v
WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = v_new_id)
ORDER BY v.sort_order;
END;
END;
$function$
LANGUAGE plpgsql VOLATILE;

View File

@ -14,3 +14,4 @@ aiosqlite==0.20.0
asyncpg==0.30.0
pytest==8.3.2
pytest-asyncio==0.24.0
openpyxl==3.1.5

View File

@ -36,9 +36,9 @@ async def get_user_by_token(
user_repo = UserRepository(db)
user = await user_repo.get_by_username(username)
if user is None:
if user is None or not user.is_active:
user = await user_repo.get_by_email(username)
if user is None:
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",

View File

@ -2,11 +2,13 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
from src.services.expense_item_service import ExpenseItemService
from src.db.models.app_user import AppUser
from src.api.v1.deps import require_admin
from src.api.v1.deps import require_admin, require_executor
from src.db.session import get_db
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
ExpenseItemResponseSchema,
)
@ -33,3 +35,28 @@ async def get_expense_item_by_id(
message="Запись Expense Item",
result=ExpenseItemResponseSchema.model_validate(result),
)
@router.get("/", response_model=BaseListResponse[ExpenseItemResponseSchema])
async def get_expense_item_by_id(
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
r_start: bool | None = None,
sheet: str | None = None,
direction: str | None = None,
form_type: FormTypeEnum | str | None = None
):
"""Получение записей expense item"""
service = ExpenseItemService(db)
result = await service.get_list(
r_start=r_start,
sheet=sheet,
direction=direction,
form_type=form_type,
)
return BaseListResponse(
success=True,
message="Запись Expense Item",
result=[ExpenseItemResponseSchema.model_validate(el) for el in result],
)

View File

@ -1,10 +1,19 @@
import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.project_service import ProjectService
from src.services.form3_phase_service import Form3PhaseService
from src.services.rf_project_report_service import RfProjectReportService
from src.services.org_unit_service import OrgUnitService
from src.db.models.app_user import AppUser
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
Form3PhaseCreate,
Form3PhaseResponse,
Form3PhaseUpdate,
FormPhaseCreate,
FormPhaseResponse,
FormPhaseUpdate,
@ -20,6 +29,18 @@ from src.db.session import get_db
router = APIRouter(prefix="/stages", tags=["stages"])
def timezone_from_offset(offset: str) -> datetime.timezone | None:
if not offset:
return None
hours = int(offset[1:])
return datetime.timezone(
datetime.timedelta(
hours=hours if offset[0] == '+' else -hours
)
)
@router.get("/form/{form_id}")
async def get_form_phases(
form_id: int,
@ -29,14 +50,30 @@ async def get_form_phases(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[FormPhaseResponse]:
fp_service = FormPhaseService(db)
org_unit_service = OrgUnitService(db)
bf_service = BudgetFormService(db)
phases = await fp_service.get_list(
budget_form_id=form_id,
user=current_user,
sheet=sheet,
phase_code=phase_code,
)
if phases:
form = await bf_service.get(user=current_user, budget_form_id=form_id)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
result = [FormPhaseResponse.model_validate(p) for p in phases]
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
for element in result:
if element.opens_at:
element.opens_at = element.opens_at.astimezone(tz)
if element.closes_at:
element.closes_at = element.closes_at.astimezone(tz)
else:
result = []
return BaseListResponse(
result=[FormPhaseResponse.model_validate(p) for p in phases],
result=result,
count=len(phases),
)
@ -53,13 +90,25 @@ async def create_form_phase(
if not form:
raise HTTPException(404, "Форма не найдена")
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
fp_service = FormPhaseService(db)
phase = await fp_service.create(
budget_form=form,
body=body,
user=current_user,
)
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
result = FormPhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
return BaseSingleResponse(result=result)
@router.patch("/form/{form_id}/{sheet}/{phase_code}")
@ -84,9 +133,21 @@ async def update_form_phase(
body=body,
user=current_user,
)
if not phase:
raise HTTPException(404, "Этап не найден")
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
result = FormPhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
return BaseSingleResponse(result=result)
@router.delete("/form/{form_id}/{sheet}/{phase_code}")
@ -111,3 +172,173 @@ async def delete_form_phase(
)
if not deleted:
raise HTTPException(404, "Этап не найден")
@router.get("/project/{project_id}")
async def get_form3_phases(
project_id: int,
year: int | None = None,
report_type: str | None = None,
phase_code: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[Form3PhaseResponse]:
f3p_service = Form3PhaseService(db)
report_service = RfProjectReportService(db)
project_service = ProjectService(db)
org_unit_service = OrgUnitService(db)
reports = await report_service.get_list(
project_id=project_id,
year=year,
report_type=report_type,
)
phases = await f3p_service.get_list(
report_id=[el.id for el in reports],
phase_code=phase_code,
)
if phases:
project = await project_service.get_instance(user=current_user, project_id=project_id)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=project.org_unit_id)
result = [Form3PhaseResponse.model_validate(p) for p in phases]
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
for element in result:
if element.opens_at:
element.opens_at = element.opens_at.astimezone(tz)
if element.closes_at:
element.closes_at = element.closes_at.astimezone(tz)
else:
result = []
if result:
reports = {report.id: report for report in reports}
for i, element in enumerate(result):
element.year = reports[phases[i].rf_project_report_id].year
element.report_type = reports[phases[i].rf_project_report_id].report_type
return BaseListResponse(
result=result,
count=len(phases),
)
@router.post("/project/{project_id}")
async def create_form3_phase(
project_id: int,
body: Form3PhaseCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[Form3PhaseResponse]:
report_service = RfProjectReportService(db)
report = await report_service.get(
project_id=project_id,
report_type=body.report_type,
year=body.year,
user=current_user,
load_project=True,
)
if not report:
raise HTTPException(404, "Форма не найдена")
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id)
f3p_service = Form3PhaseService(db)
phase = await f3p_service.create(
report_id=report.id,
body=body,
user=current_user,
)
result = Form3PhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
result.year = body.year
result.report_type = body.report_type
return BaseSingleResponse(result=result)
@router.patch("/project/{project_id}/{year}/{report_type}/{phase_code}")
async def update_form3_phase(
project_id: int,
year: int,
report_type: str,
phase_code: str,
body: Form3PhaseUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[Form3PhaseResponse]:
report_service = RfProjectReportService(db)
report = await report_service.get(
project_id=project_id,
report_type=report_type,
year=year,
user=current_user,
load_project=True,
)
if not report:
raise HTTPException(404, "Форма не найдена")
f3p_service = Form3PhaseService(db)
phase = await f3p_service.update(
report_id=report.id,
phase_code=phase_code,
body=body,
user=current_user,
)
if not phase:
raise HTTPException(404, "Этап не найден")
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id)
result = Form3PhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
result.year = year
result.report_type = report_type
return BaseSingleResponse(result=result)
@router.delete("/project/{project_id}/{year}/{report_type}/{phase_code}")
async def delete_form3_phase(
project_id: int,
year: int,
report_type: str,
phase_code: str,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
report_service = RfProjectReportService(db)
report = await report_service.get(
project_id=project_id,
report_type=report_type,
year=year,
user=current_user,
load_project=True,
)
if not report:
raise HTTPException(404, "Форма не найдена")
f3p_service = Form3PhaseService(db)
deleted = await f3p_service.delete(
report_id=report.id,
phase_code=phase_code,
user=current_user,
)
if not deleted:
raise HTTPException(404, "Этап не найден")

View File

@ -1,3 +1,4 @@
import logging
import time
from typing import Optional
@ -20,6 +21,9 @@ SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
logger = logging.getLogger()
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
return [
SheetResponse(
@ -153,6 +157,20 @@ async def get_sheet(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_sheet",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -188,12 +206,6 @@ async def update_cell(
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
budget_line_service = BudgetLineService(db)
budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user)
if not budget_line or budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")
result = await sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
@ -206,6 +218,22 @@ async def update_cell(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "update_form_cell",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
"line_id": cell_body.line_id,
"column": cell_body.column,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -243,14 +271,6 @@ async def update_cells(
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
budget_line_service = BudgetLineService(db)
budget_lines = await budget_line_service.get_list(
budget_line_ids=[cb.line_id for cb in cells_body.changes], user=current_user)
for budget_line in budget_lines:
if budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")
result = await sheet_service.update_cells(
form_id=form_id,
sheet=sheet,
@ -261,6 +281,21 @@ async def update_cells(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "update_form_cells",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
"count": len(cells_body.changes)
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -310,6 +345,21 @@ async def add_line(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "add_form_line",
"form_id": form_id,
"sheet": sheet,
"direction": body.direction,
"user": current_user.id,
"expense_item_id": body.expense_item_id
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -351,6 +401,21 @@ async def delete_line(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "del_form_line",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"user": current_user.id,
"line_id": row_id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -376,7 +441,7 @@ async def add_form(
)
raise HTTPException(404, f"ССП {not_found_ids} не найдены")
new_forms = await bf_service.bulk_create(
new_forms = await bf_service.bulk_create_if_not_exists(
form_type_code=form.form_type_code,
year=form.year,
org_unit_ids=org_unit_ids,
@ -384,6 +449,18 @@ async def add_form(
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "add_form",
"form_type_code": form.form_type_code,
"user": current_user.id,
"count_orgs": len(form.org_unit_ids),
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],

View File

@ -6,7 +6,7 @@ from src.core.errors import AccessDeniedException
from src.api.v1.deps import get_current_active_user, require_admin
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import BaseListResponse, BaseSingleResponse, OrgUnitBaseSchema, OrgUnitListSchema, OrgUnitSchema, OrgUnitUpdateSchema, ResponseBase
from src.domain.schemas import BaseListResponse, BaseSingleResponse, OrgUnitCreateSchema, OrgUnitListSchema, OrgUnitSchema, OrgUnitUpdateSchema, ResponseBase
from src.services.org_unit_service import OrgUnitService
@ -66,7 +66,7 @@ async def org_unit_id(
@router.post("/")
async def create_ssp(
org_data: OrgUnitBaseSchema,
org_data: OrgUnitCreateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
@ -75,6 +75,7 @@ async def create_ssp(
org_unit = await org_unit_service.create(
title=org_data.title,
is_ssp=org_data.is_ssp,
utc_offset=org_data.utc_offset,
user=current_user,
)
return BaseSingleResponse(

View File

@ -1,3 +1,4 @@
import logging
import time
from typing import Optional
@ -20,6 +21,9 @@ from src.domain.schemas import (
from src.services.project_service import ProjectService
logger = logging.getLogger()
router = APIRouter(tags=["forms"])
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
@ -96,6 +100,20 @@ async def get_project_report(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_project_report",
"project_id": project_id,
"year": year,
"report_type": report_type,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
@ -118,6 +136,19 @@ async def get_rf_rollup(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_project_report",
"branch_id": branch_id,
"year": year,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))

View File

@ -27,9 +27,9 @@ async def get_vsp(
ssp_id: int | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_max: int | None = None,
placement_type: str | None = None,
staff_count_min: int | None = None,
staff_count_max: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
@ -42,9 +42,9 @@ async def get_vsp(
ssp_id=ssp_id,
open_date_start=open_date_start,
open_date_end=open_date_end,
location_form=location_form,
numbers_min=numbers_min,
numbers_max=numbers_max,
placement_type=placement_type,
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
load_org_unit=True,
with_count=True,
)
@ -89,9 +89,9 @@ async def export_info(
ssp_ids=body.ssp_ids,
open_date_start=body.open_date_start,
open_date_end=body.open_date_end,
location_form=body.location_form,
numbers_min=body.numbers_min,
numbers_max=body.numbers_max,
placement_type=body.placement_type,
staff_count_min=body.staff_count_min,
staff_count_max=body.staff_count_max,
)
return StreamingResponse(
stream,

View File

@ -4,6 +4,8 @@ from dataclasses import asdict
import dataclasses
import enum
from json import dumps, loads
import logging
import time
from typing import Any, Optional
#
@ -12,6 +14,9 @@ from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.sheet_repository import SheetValidationError
from src.services.rf_project_report_service import RfProjectReportService
from src.services.rf_project_report_line_service import RfProjectReportLineService
from src.repository.user_repository import UserRepository
from src.services.budget_line_service import BudgetLineService
from src.api.v1.deps import get_user_by_token
@ -26,6 +31,9 @@ from src.db.session import SessionLocal
from src.services.user_service import UserService
logger = logging.getLogger()
@asynccontextmanager
async def get_db_session(user_id: int | None = None):
"""Контекстный менеджер для получения сессии базы данных."""
@ -265,7 +273,7 @@ class ConnectionManager:
for lock_key in lock_keys:
self.cell_locks.pop(lock_key, None)
def is_locked_by_other(
def is_cell_available_for_edit(
self,
con_key: ConnectionKeyEnum,
user_id: int,
@ -273,10 +281,10 @@ class ConnectionManager:
form_key,
) -> bool:
if not cell_key:
return False
return True
cell_key = frozenset(cell_key.items())
lock_owner_id = self.cell_locks.get((con_key, frozenset(form_key.items()), cell_key))
return lock_owner_id is not None and lock_owner_id != user_id
return lock_owner_id is not None and lock_owner_id == user_id
def is_row_locked_by_other(
self,
@ -369,6 +377,18 @@ class FormEventProcess:
sheet=sheet,
direction=direction,
)
case "program_added":
return await self.__add_program(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
)
case "project_added":
return await self.__add_project(
event_data=event_data["data"],
user=curr_user,
)
case _:
pass
@ -455,6 +475,41 @@ class FormEventProcess:
return final_result
return final_result
async def __add_program(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
name: str
}
"""
return await self.sheet_service.add_program(
form_id=form_id,
sheet=sheet,
name=event_data["name"],
user=user,
)
async def __add_project(
self,
event_data: dict,
user: AppUser,
) -> list[tuple]:
"""
data = {
name: str,
program_id: int
}
"""
return await self.sheet_service.add_project(
name=event_data["name"],
program_id=event_data["program_id"],
user=user,
)
async def __del_row(
self,
@ -485,7 +540,8 @@ class FormEventProcess:
class ProjectEventProcess:
def __init__(self, db: AsyncSession):
self.project_service: ProjectService = ProjectService(db)
self.bf_service: BudgetFormService = BudgetFormService(db)
self.prl_service: RfProjectReportLineService = RfProjectReportLineService(db)
self.pr_service: RfProjectReportService = RfProjectReportService(db)
self.user_service: UserService = UserService(db)
async def process(
@ -568,11 +624,16 @@ class ProjectEventProcess:
expense_item_id: Optional[int] = None
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
project_report = await self.pr_service.get(
year=year,
report_type=report_type,
project_id=project_id,
)
if not project_report:
return None
return await self.project_service.add_form3_line(
ids = await self.prl_service.get_ids(project_report_id=project_report.id)
result = await self.project_service.add_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
@ -580,6 +641,16 @@ class ProjectEventProcess:
user=user,
)
final_result = {
"data": result,
"new_line_id": None,
}
for el in result:
if el[3]["line_id"] and el[3]["line_id"] not in ids:
final_result["new_line_id"] = el[3]["line_id"]
return final_result
return final_result
async def __del_row(
self,
event_data: dict,
@ -590,7 +661,7 @@ class ProjectEventProcess:
) -> list[tuple]:
"""
data = {
"line_id": int,
"row_id": int,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
@ -601,7 +672,7 @@ class ProjectEventProcess:
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["line_id"],
line_id=event_data["row_id"],
user=user,
)
@ -639,7 +710,7 @@ def resolve_cell_key(event_data: dict) -> dict | None:
if not data or "line_id" not in data or "column" not in data:
return None
return {
"line_id": data["line_id"],
"line_id": data.get("line_id_code", data["line_id"]),
"column": data["column"],
}
@ -727,7 +798,7 @@ async def process_websocket(
)
continue
case "cell_updated":
if manager.is_locked_by_other(
if not manager.is_cell_available_for_edit(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
@ -739,18 +810,18 @@ async def process_websocket(
websocket,
)
continue
if cell_key and not manager.acquire_cell_lock(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
form_key=kwargs_process,
):
data["error"] = "Ячейка уже редактируется другим пользователем"
await manager.send_back(
data,
websocket,
)
continue
# if cell_key and not manager.acquire_cell_lock(
# con_key=con_key,
# user_id=user_id,
# cell_key=cell_key,
# form_key=kwargs_process,
# ):
# data["error"] = "Ячейка уже редактируется другим пользователем"
# await manager.send_back(
# data,
# websocket,
# )
# continue
case "row_deleted":
if manager.is_row_locked_by_other(
con_key=con_key,
@ -770,12 +841,26 @@ async def process_websocket(
async with get_db_session(user_id=user_id) as db:
processor = processor_cls(db)
t0 = time.perf_counter()
data_old = data.copy()
data["result"] = await processor.process(
event_data=data,
user_id=user_id,
**kwargs_process
)
await db.commit()
db_ms = (time.perf_counter() - t0) * 1000
extra = {
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"event": data.get("event"),
}
extra.update(data_old)
logger.info(
f"db_time: {extra}",
extra=extra,
)
if data.get("event") == "row_deleted":
manager.release_locks_for_row(
@ -787,6 +872,8 @@ async def process_websocket(
if data.get("event") in [
"cell_updated",
"row_added",
"program_added",
"project_added",
"row_deleted",
]:
await manager.broadcast_to_all(
@ -815,6 +902,12 @@ async def process_websocket(
data=data,
websocket=websocket,
)
except SheetValidationError as e:
data["error"] = e.message
await manager.send_back(
data=data,
websocket=websocket,
)
except Exception as e:
tp = type(e)

View File

@ -9,7 +9,9 @@ from src.db.models.ckk import Ckk
from src.db.models.collegial_approval import CollegialApproval
from src.db.models.contract_detail import ContractDetail
from src.db.models.contract_summary import ContractSummary
from src.db.models.expense_item import ExpenseItem
from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType
from src.db.models.form3_phase import Form3Phase
from src.db.models.form4_project import Form4Project
from src.db.models.form_limit import FormLimit
from src.db.models.form_phase import FormPhase
from src.db.models.form_type import FormType

View File

@ -25,3 +25,12 @@ class ExpenseItem(Base):
direction: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
depth: Mapped[int | None] = mapped_column(Integer, default=0)
class ExpenseItemFormType(Base):
__tablename__ = "expense_item_form_type"
__table_args__ = {"schema": "v3"}
form_type_code: Mapped[str] = mapped_column(String, primary_key=True)
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"), primary_key=True)

View File

@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class Form3Phase(Base):
__tablename__ = "form3_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v3_form3_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v3_form3_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v3_form3_phase_window"),
Index("ix_v3_form3_phase_active", "rf_project_report_id", "role", "opens_at", "closes_at"),
Index("ix_v3_form3_phase_columns_gin", "column_keys", postgresql_using="gin"),
{"schema": "v3"},
)
rf_project_report_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report.id", ondelete="CASCADE"), primary_key=True
)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -0,0 +1,54 @@
from __future__ import annotations
import enum
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Form4ProjectLevelEnum(str, enum.Enum):
PROGRAM = "program"
PROJECT = "project"
class Form4Project(Base):
__tablename__ = "form4_project"
__table_args__ = (
CheckConstraint(
"level IN ('program', 'project')", name="chk_form4_project_level"
),
CheckConstraint(
"(level = 'program' AND parent_id IS NULL) OR (level = 'project')",
name="chk_form4_project_parent",
),
CheckConstraint(
"(level = 'program') = (section_code IS NOT NULL)",
name="chk_form4_project_section_code",
),
Index(
"ix_form4_project_section_code",
"section_code",
postgresql_where=text("level = 'program'"),
),
Index("ix_v3_form4_project_parent", "parent_id"),
Index("ix_v3_form4_project_form", "form_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String)
level: Mapped[Form4ProjectLevelEnum] = mapped_column(String) # type: ignore
section_code: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.form4_project.id")
)
form_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.budget_form.id")
)
# parent = relationship("Form4Project", remote_side="Form4Project.id", back_populates="children")
# children: Mapped[list["Form4Project"]] = relationship("Form4Project", back_populates="parent") # type: ignore
# budget_form = relationship("BudgetForm")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="form4_project") # type: ignore

View File

@ -21,6 +21,7 @@ class OrgUnit(Base):
title: Mapped[str] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_ssp: Mapped[bool] = mapped_column(Boolean)
utc_offset: Mapped[str] = mapped_column(String(3), default="+03")
@property
def users_count(self) -> int | None:

View File

@ -22,8 +22,8 @@ class PhaseTemplate(Base):
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
# form_type_rel = relationship("FormType", foreign_keys=[form_type])
# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates")

View File

@ -46,4 +46,4 @@ class Project(Base):
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
# org_unit = relationship("OrgUnit", back_populates="projects")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore
rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore

View File

@ -25,7 +25,7 @@ class RfProjectReport(Base):
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports")
project: Mapped["Project"] = relationship("Project", back_populates="rf_project_reports") # type: ignore
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")

View File

@ -45,8 +45,6 @@ class Vsp(Base):
)
vsp_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
location_form: Mapped[str | None] = mapped_column(String, nullable=True)
numbers: Mapped[int | None] = mapped_column(Integer, nullable=True)
rent_contract_num: Mapped[str | None] = mapped_column(String, nullable=True)
rent_end_date : Mapped[date | None]= mapped_column(Date, nullable=True)

View File

@ -233,10 +233,16 @@ class OrgUnitBaseSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
class OrgUnitCreateSchema(OrgUnitBaseSchema):
title: str = Field(..., min_length=1)
utc_offset: str = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
class OrgUnitUpdateSchema(OrgUnitBaseSchema):
title: Optional[str] = None
is_ssp: Optional[bool] = None
is_active: Optional[bool] = None
utc_offset: Optional[str] = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
model_config = ConfigDict(from_attributes=True)
@ -244,6 +250,7 @@ class OrgUnitUpdateSchema(OrgUnitBaseSchema):
class OrgUnitResponseSchema(OrgUnitBaseSchema):
id: int
is_active: bool
utc_offset: str
users_count: int | None = None
model_config = ConfigDict(from_attributes=True)
@ -261,6 +268,7 @@ class OrgUnitResponseSchema(OrgUnitBaseSchema):
"is_ssp": data.is_ssp,
"is_ssp": data.is_ssp,
"is_active": data.is_active,
"utc_offset": data.utc_offset
}
try:
result["users_count"] = data.users_count
@ -358,7 +366,7 @@ class AddProjectBody(BaseModel):
max_length=30,
pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
)
year: int
year: int = Field(default_factory=lambda: datetime.now().year)
branch_id: int
level: Literal["project", "program"] = "project"
parent_id: Optional[int] = None
@ -370,6 +378,10 @@ class AddProjectBody(BaseModel):
staff_count: Optional[int] = None
total_area: Optional[float] = None
@field_validator("year", mode="before")
@classmethod
def optional_year(cls, v: Any) -> Any:
return v or datetime.now().year
class AddLineSchema(BaseModel):
expense_item_id: Optional[int] = None
@ -413,6 +425,35 @@ class FormPhaseUpdate(BaseModel):
closes_at: datetime | None = None
class Form3PhaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
phase_code: str
role: str
column_keys: list[str]
opens_at: datetime
closes_at: datetime
year: int | None = None
report_type: str | None = None
class Form3PhaseCreate(BaseModel):
year: int
report_type: str
phase_code: str
role: FormPhaseRole
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class Form3PhaseUpdate(BaseModel):
role: FormPhaseRole | None = None
column_keys: list[str] | None = None
opens_at: datetime | None = None
closes_at: datetime | None = None
class AuditLogBase(BaseModel):
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
@ -490,8 +531,8 @@ class VSPBase(BaseModel):
serialization_alias='approved_format',
)
notes: Optional[str] = Field(None, description="Примечания")
location_form: Optional[str] = Field(None, description="Форма расположения")
numbers: Optional[int] = Field(None, description="Штатная численность")
placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность")
area: Optional[float] = Field(
None,
description="Арендная площадь",
@ -539,8 +580,8 @@ class VSPEditBase(BaseModel):
serialization_alias='format',
)
notes: Optional[str] = Field(None, description="Примечания")
location_form: Optional[str] = Field(None, description="Форма расположения")
numbers: Optional[int] = Field(None, description="Штатная численность")
placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность")
total_area: Optional[float] = Field(
None,
description="Арендная площадь",
@ -611,9 +652,9 @@ class VSPExportRequest(BaseModel):
ssp_ids: Optional[List[int]] = None
open_date_start: Optional[date] = None
open_date_end: Optional[date] = None
location_form: Optional[str] = None
numbers_min: Optional[int] = None
numbers_max: Optional[int] = None
placement_type: Optional[str] = None
staff_count_min: Optional[int] = None
staff_count_max: Optional[int] = None
class ExpenseItemBaseSchema(BaseModel):

View File

@ -1,4 +1,5 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@ -20,6 +21,10 @@ from src.core.exception_handlers import register_exception_handlers
from src.db.base import engine
from src.db.session import create_tables
logging.basicConfig(level=logging.INFO)
# if not settings.DEBUG:
# from raisa_fastapi_protected_api import (
# AuthorizationMiddleware,

View File

@ -1,6 +1,7 @@
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
from src.db.models.budget_form import BudgetForm
from sqlalchemy.orm import joinedload
@ -58,20 +59,35 @@ class BudgetFormRepository:
async def get(
self,
budget_form_id: int,
budget_form_id: int | None = None,
org_unit: int | list[int] | None = None,
form_type_code: FormTypeEnum | str | None = None,
year: int | None = None,
load_form_type: bool = False,
load_org: bool = False,
) -> BudgetForm | None:
assert budget_form_id or all((form_type_code, year, org_unit))
query = select(BudgetForm)
where = [BudgetForm.id == budget_form_id]
where = []
if budget_form_id is not None:
where.append(BudgetForm.id == budget_form_id)
if org_unit is not None:
if isinstance(org_unit, int):
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
if form_type_code is not None:
if isinstance(form_type_code, FormTypeEnum):
where.append(BudgetForm.form_type_code == form_type_code.value)
else:
where.append(BudgetForm.form_type_code == form_type_code)
if year is not None:
where.append(BudgetForm.year == year)
query = query.where(*where)
if load_org:
query = query.options(

View File

@ -12,7 +12,7 @@ class BudgetLineRepository:
query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, budget_line_ids: list[int]) -> BudgetLine | None:
async def get_list(self, budget_line_ids: list[int]) -> list[BudgetLine]:
query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids))
return (await self.db.execute(query)).scalars().all()

View File

@ -1,7 +1,8 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.expense_item import ExpenseItem
from src.db.models.form_type import FormTypeEnum
from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType
class ExpenseItemRepository:
@ -11,3 +12,32 @@ class ExpenseItemRepository:
async def get(self, item_id: int) -> ExpenseItem | None:
query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(
self,
r_start: bool | None = None,
sheet: str | None = None,
direction: str | None = None,
form_type: FormTypeEnum | str | None = None,
) -> list[ExpenseItem]:
where = []
query = select(ExpenseItem)
if form_type is not None:
query = query.join(
ExpenseItemFormType,
ExpenseItemFormType.expense_item_id == ExpenseItem.id,
).where(
ExpenseItemFormType.form_type_code == (form_type.value if isinstance(form_type, FormTypeEnum) else form_type)
)
if r_start is not None:
where.append(ExpenseItem.item_id.startswith("R"))
if sheet is not None:
where.append(ExpenseItem.sheet == sheet)
if direction is not None:
where.append(ExpenseItem.direction == direction)
query = query.where(*where)
return (await self.db.execute(query)).scalars().all()

View File

@ -0,0 +1,116 @@
from datetime import datetime
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form3_phase import Form3Phase
class Form3PhaseRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
report_id: int | list[int],
phase_code: str | None = None,
) -> list[Form3Phase]:
if isinstance(report_id, int):
query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id)
else:
query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id))
if phase_code is not None:
query = query.where(Form3Phase.phase_code == phase_code)
return (await self.db.execute(query)).scalars().all()
async def get(
self, report_id: int, phase_code: str
) -> Form3Phase | None:
query = (
select(Form3Phase)
.where(Form3Phase.rf_project_report_id == report_id)
.where(Form3Phase.phase_code == phase_code)
.limit(1)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def create(
self,
report_id: int,
phase_code: str,
role: str,
column_keys: list[str],
opens_at: datetime,
closes_at: datetime,
) -> Form3Phase:
result = await self.db.execute(
text(
"""
SELECT
(v3.add_form3_phase(
:report_id,
:phase_code,
:role,
:column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).rf_project_report_id
"""
),
{
"report_id": report_id,
"phase_code": phase_code,
"role": role,
"column_keys": column_keys,
"opens_at": opens_at,
"closes_at": closes_at,
},
)
if result.scalar_one_or_none() is None:
return None
return await self.get(report_id, phase_code)
async def update(
self, report_id: int, phase_code: str, data: dict
) -> Form3Phase | None:
result = await self.db.execute(
text(
"""
SELECT
(v3.upd_form3_phase(
:report_id,
:phase_code,
:role,
:column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).rf_project_report_id
"""
),
{
"report_id": report_id,
"phase_code": phase_code,
"role": data.get("role"),
"column_keys": data.get("column_keys"),
"opens_at": data.get("opens_at"),
"closes_at": data.get("closes_at"),
},
)
if result.scalar_one_or_none() is None:
return None
return await self.get(report_id, phase_code)
async def delete(
self, report_id: int, phase_code: str
) -> bool:
result = await self.db.execute(
text(
"SELECT v3.del_form3_phase(:report_id, :phase_code)"
),
{
"report_id": report_id,
"phase_code": phase_code,
},
)
deleted = result.scalar_one_or_none()
return bool(deleted)

View File

@ -0,0 +1,65 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form4_project import Form4Project, Form4ProjectLevelEnum
class Form4ProjectRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
project_id: int,
level: Form4ProjectLevelEnum | str | None = None,
) -> Form4Project | None:
where = [Form4Project.id == project_id]
if level is not None:
where.append(Form4Project.level == level)
return (
(await self.db.execute(select(Form4Project).where(*where).limit(1)))
.scalars()
.first()
)
async def get_list(
self,
level: Form4ProjectLevelEnum | None = None,
form_id: int | None = None,
parent_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
) -> list[Form4Project] | tuple[int, list[Form4Project]]:
if with_count:
query = select(func.count().over().label("total_count"), Form4Project)
else:
query = select(Form4Project)
where = []
if level is not None:
where.append(Form4Project.level == level)
if form_id is not None:
where.append(Form4Project.form_id == form_id)
if parent_id is not None:
where.append(Form4Project.parent_id == parent_id)
query = query.where(*where).order_by(Form4Project.id)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
if with_count:
result = (await self.db.execute(query)).all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(Form4Project.id))
if query.whereclause is not None:
count_query = count_query.where(query.whereclause)
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()

View File

@ -55,8 +55,8 @@ class FormPhaseRepository:
:phase_code,
:role,
:column_keys,
:opens_at,
:closes_at
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).budget_form_id
"""
),
@ -87,8 +87,8 @@ class FormPhaseRepository:
:phase_code,
:role,
:column_keys,
:opens_at,
:closes_at
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).budget_form_id
"""
),

View File

@ -1,6 +1,6 @@
from sqlalchemy import func, select, update
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit
@ -84,9 +84,10 @@ class OrgUnitRepository:
self,
title: str,
is_ssp: bool,
utc_offset: str,
) -> OrgUnit:
"""Создание SSP."""
ssp = OrgUnit(title=title, is_ssp=is_ssp)
ssp = OrgUnit(title=title, is_ssp=is_ssp, utc_offset=utc_offset)
self.db.add(ssp)
await self.db.flush()
# await self.db.refresh(ssp)
@ -98,10 +99,29 @@ class OrgUnitRepository:
if not org_unit:
return None
old_offset = org_unit.utc_offset
for field, value in kwargs.items():
setattr(org_unit, field, value)
await self.db.flush()
if org_unit.utc_offset != old_offset:
await self.db.execute(
text(
"""
SELECT v3.upd_phases_timezones(
:org_unit_id,
:old_tz,
:new_tz
)
"""
),
{
"org_unit_id": org_unit.id,
"old_tz": old_offset,
"new_tz": org_unit.utc_offset,
},
)
# await self.db.refresh(ssp)
return org_unit

View File

@ -14,7 +14,7 @@ class ProjectRepository:
self.db = db
@staticmethod
def _serialize_project(project: Project, org_unit_name: str | None, report_count: int) -> dict:
def _serialize_project(project: Project, org_unit_name: str | None, report_count: int, years: list[InterruptedError] | None = None) -> dict:
return {
"id": project.id,
"name": project.name,
@ -29,6 +29,7 @@ class ProjectRepository:
"org_unit_id": project.org_unit_id,
"org_unit_name": org_unit_name,
"report_count": int(report_count),
"years": years,
}
@staticmethod
@ -66,11 +67,19 @@ class ProjectRepository:
.scalar_subquery()
)
report_years_subq = (
select(RfProjectReport.year)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
func.array(report_years_subq).label("years"),
)
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.order_by(Project.id)
@ -82,7 +91,7 @@ class ProjectRepository:
query = query.limit(limit)
rows = (await self.db.execute(query)).all()
payload = [self._serialize_project(row[0], row[1], row[2]) for row in rows]
payload = [self._serialize_project(row[0], row[1], row[2], row[3]) for row in rows]
if not with_count:
return payload
@ -101,11 +110,18 @@ class ProjectRepository:
.where(RfProjectReport.project_id == Project.id)
.scalar_subquery()
)
report_years_subq = (
select(RfProjectReport.year)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
func.array(report_years_subq).label("years"),
)
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.where(Project.id == project_id)
@ -117,7 +133,24 @@ class ProjectRepository:
row = (await self.db.execute(query)).first()
if not row:
return None
return self._serialize_project(row[0], row[1], row[2])
return self._serialize_project(row[0], row[1], row[2], row[3])
async def get_instance(
self,
project_id: int,
org_unit_ids: list[int] | None = None,
) -> Project | None:
query = (
select(
Project,
)
.where(Project.id == project_id)
)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
return None
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return (await self.db.execute(query)).scalar_one_or_none()
async def get_reports(self, project_id: int) -> list[dict]:
line_count_subq = (

View File

@ -0,0 +1,39 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report_line import RfProjectReportLine
class RfProjectReportLineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, line_id: int) -> RfProjectReportLine | None:
query = select(
RfProjectReportLine
).where(
RfProjectReportLine.id == line_id
).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, line_ids: list[int]) -> list[RfProjectReportLine]:
query = select(
RfProjectReportLine
).where(
RfProjectReportLine.id.in_(line_ids)
)
return (await self.db.execute(query)).scalars().all()
async def get_ids(
self,
project_report_id: int,
) -> list[int]:
query = select(
RfProjectReportLine.id
).where(
RfProjectReportLine.rf_project_report_id == project_report_id
)
return (await self.db.execute(query)).scalars().all()

View File

@ -0,0 +1,58 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from src.db.models.rf_project_report import RfProjectReport
class RfProjectReportRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
load_project: bool = False,
) -> RfProjectReport | None:
assert report_id is not None or all((el is not None for el in (year, report_type, project_id)))
query = select(
RfProjectReport
)
if report_id is not None:
query = query.where(RfProjectReport.id == report_id)
else:
query = query.where(
RfProjectReport.year == year,
RfProjectReport.report_type == report_type,
RfProjectReport.project_id == project_id
)
if load_project:
query = query.options(joinedload(RfProjectReport.project))
query = query.limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(
self,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
) -> list[RfProjectReport]:
query = select(
RfProjectReport
)
if report_id is not None:
query = query.where(RfProjectReport.id == report_id)
if year is not None:
query = query.where(RfProjectReport.year == year)
if report_type is not None:
query = query.where(RfProjectReport.report_type == report_type)
if project_id is not None:
query = query.where(RfProjectReport.project_id == project_id)
return (await self.db.execute(query)).scalars().all()

View File

@ -161,6 +161,59 @@ class SheetRepository:
).all()
]
async def add_program(
self,
form_id: int,
sheet: str,
name: str,
) -> list[tuple]:
return [
tuple(r) for r in (
await self.db.execute(
text(
"""
SELECT project_id, row_type, depth, sort_order, data
FROM v3.add_form4_program(
:p_name,
:p_form_id,
:p_sheet
)
"""
),
{
"p_form_id": form_id,
"p_name": name,
"p_sheet": sheet,
}
)
).all()
]
async def add_project(
self,
name: str,
program_id: int,
) -> list[tuple]:
return [
tuple(r) for r in (
await self.db.execute(
text(
"""
SELECT project_id, row_type, depth, sort_order, data
FROM v3.add_form4_project(
:p_name,
:p_program_id
)
"""
),
{
"p_program_id": program_id,
"p_name": name,
}
)
).all()
]
async def add_line(
self,
form_id: int,

View File

@ -36,9 +36,9 @@ class VSPRepository:
ssp_ids: list[int] | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_max: int | None = None,
placement_type: str | None = None,
staff_count_min: int | None = None,
staff_count_max: int | None = None,
is_active: bool | None = True,
close_date_is_null: bool | None = None,
load_org_unit: bool = False,
@ -53,11 +53,11 @@ class VSPRepository:
filters = [
("registration_number", registration_number),
("address", address),
("location_form", location_form),
("placement_type", placement_type),
("open_date_start", open_date_start),
("open_date_end", open_date_end),
("numbers_min", numbers_min),
("numbers_max", numbers_max),
("staff_count_min", staff_count_min),
("staff_count_max", staff_count_max),
("is_active", is_active),
]
for filter_name, filter_value in filters:
@ -67,16 +67,16 @@ class VSPRepository:
queries.append(Vsp.reg_number.ilike(f"%{value}%"))
case ("address", str(value)) if value:
queries.append(Vsp.address.ilike(f"%{value}%"))
case ("location_form", str(value)) if value:
queries.append(Vsp.location_form.ilike(f"%{value}%"))
case ("placement_type", str(value)) if value:
queries.append(Vsp.placement_type.ilike(f"%{value}%"))
case ("open_date_start", value) if value is not None:
queries.append(Vsp.opened_at >= value)
case ("open_date_end", value) if value is not None:
queries.append(Vsp.opened_at <= value)
case ("numbers_min", value) if value is not None:
queries.append(Vsp.numbers >= value)
case ("numbers_max", value) if value is not None:
queries.append(Vsp.numbers <= value)
case ("staff_count_min", value) if value is not None:
queries.append(Vsp.staff_count >= value)
case ("staff_count_max", value) if value is not None:
queries.append(Vsp.staff_count <= value)
case ("is_active", value) if value is not None:
queries.append(Vsp.is_active == value)
case _:
@ -116,6 +116,7 @@ class VSPRepository:
async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp:
data = payload.model_dump()
vsp_id = (
await self.db.execute(
text("""
@ -124,8 +125,7 @@ class VSPRepository:
:p_opened_at, :p_placement_type, :p_staff_count,
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
:p_system_code, :p_created_by, :p_vsp_type, :p_notes,
:p_location_form, :p_numbers, :p_rent_contract_num,
:p_rent_end_date
:p_rent_contract_num, :p_rent_end_date
)
"""),
{
@ -144,8 +144,6 @@ class VSPRepository:
"p_created_by": created_by,
"p_vsp_type": data.get("vsp_type"),
"p_notes": data.get("notes"),
"p_location_form": data.get("location_form"),
"p_numbers": data.get("numbers"),
"p_rent_contract_num": data.get("rent_contract_num"),
"p_rent_end_date": data.get("rent_end_date"),
}
@ -176,9 +174,9 @@ class VSPRepository:
"opened_at": "p_opened_at",
"closed_at": "p_closed_at",
"format": "p_format",
"placement_type": "p_placement_type",
"staff_count": "p_staff_count",
"notes": "p_notes",
"location_form": "p_location_form",
"numbers": "p_numbers",
"total_area": "p_total_area",
"rent_contract_num": "p_rent_contract_num",
"rent_end_date": "p_rent_end_date",
@ -190,8 +188,7 @@ class VSPRepository:
params = {"p_vsp_id": vsp.id, "p_updated_by": updated_by}
for field, param in FIELD_TO_PARAM.items():
params[param] = data.get(field)
params.setdefault("p_placement_type", data.get("placement_type"))
params.setdefault("p_staff_count", data.get("staff_count"))
await self.db.execute(
text("""
SELECT v3.upd_vsp(
@ -199,8 +196,7 @@ class VSPRepository:
:p_format, :p_opened_at, :p_placement_type, :p_staff_count,
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
:p_system_code, :p_updated_by, :p_vsp_type, :p_notes,
:p_location_form, :p_numbers, :p_rent_contract_num,
:p_rent_end_date
:p_rent_contract_num, :p_rent_end_date
)
"""),
params,

View File

@ -14,7 +14,7 @@ class AuthService:
async def authenticate_user(self, username: str, password: str) -> Optional[Token]:
user = await self.user_repo.authenticate(username, password)
if not user:
if not user or not user.is_active:
return None
access_token = create_access_token(data={"sub": user.username})
refresh_token = create_refresh_token(data={"sub": user.username})
@ -26,7 +26,7 @@ class AuthService:
async def authenticate_user_via_email(self, email: str) -> Optional[Token]:
user = await self.user_repo.authenticate_via_email(email)
if not user:
if not user or not user.is_active:
return None
access_token = create_access_token(data={"sub": user.email})
refresh_token = create_refresh_token(data={"sub": user.email})

View File

@ -80,6 +80,25 @@ class BudgetFormService:
org_unit_id=org_unit_id,
)
async def create_if_not_exists(
self,
form_type_code: str,
year: int,
org_unit_id: int,
) -> BudgetForm | None:
existing_bf = await self.bf_repo.get(
form_type_code=form_type_code,
year=year,
org_unit=org_unit_id,
)
if existing_bf:
return existing_bf
return await self.bf_repo.create(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
)
async def bulk_create(
self,
form_type_code: str,
@ -96,3 +115,20 @@ class BudgetFormService:
for org_unit_id in org_unit_ids
]
)
async def bulk_create_if_not_exists(
self,
form_type_code: str,
year: int,
org_unit_ids: list[int],
) -> BudgetForm | None:
return await asyncio.gather(
*[
self.create_if_not_exists(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
)
for org_unit_id in org_unit_ids
]
)

View File

@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.db.models.expense_item import ExpenseItem
@ -10,7 +11,25 @@ class ExpenseItemService:
def __init__(self, db: AsyncSession):
self.repo = ExpenseItemRepository(db)
async def get(self, item_id: int, user: AppUser | None = None) -> ExpenseItem | None:
async def get(
self,
item_id: int,
user: AppUser | None = None,
) -> ExpenseItem | None:
if not user or user.role_id == UserRoleEnum.ADMIN:
return await self.repo.get(item_id=item_id)
return None
async def get_list(
self,
r_start: bool | None = None,
sheet: str | None = None,
direction: str | None = None,
form_type: FormTypeEnum | str | None = None,
) -> list[ExpenseItem]:
return await self.repo.get_list(
r_start=r_start,
sheet=sheet,
direction=direction,
form_type=form_type,
)

View File

@ -1185,6 +1185,238 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
FORM2_DEPTH_AHR_SUB_COLUMNS: dict = {
"address": {
"key": None,
"name": "Адрес арендуемого помещения *",
"children": {
"address": {
"name": '(заполняется авто из табл. "INFO" в зависимости от выбранного объекта)'
}
}
},
"object": {
"key": None,
"name": "Объект **",
"children": {
"object_type": {
"name": '(необходимо выбрать из списка)'
}
}
},
"area": {
"key": None,
"name": "Арендуемая площадь",
"children": {
"rented_area": {
"name": 'в кв.м.'
}
}
},
"date_end": {
"key": None,
"name": "Срок окончания договора",
"children": {
"contract_end_date": {
"name": 'в формате "месяц.год"'
}
}
},
"contract_number": {
"name": 'Номер договора'
},
"plan": {
# "key": None,
"name": "Планируемая сумма расходов на 2026 год",
"children": {
"q1": {
"name": "I квартал"
},
"q2": {
"name": "II квартал"
},
"q3": {
"name": "III квартал"
},
"q4": {
"name": "IV квартал"
}
}
},
"comment": {
"name": "Комментарий",
},
"fact_q1": {
# "key": None,
"name": "Фактические расходы за I кв 2026 года",
"children": {
"up_jan": {
"key": None,
"name": "январь",
"children": {
"jan": {
"name": "в тыс. рублей"
},
}
},
"up_feb": {
"key": None,
"name": "февраль",
"children": {
"feb": {
"name": "в тыс. рублей"
},
}
},
"up_mar": {
"key": None,
"name": "март",
"children": {
"mar": {
"name": "в тыс. рублей"
},
}
},
"up_total": {
"key": None,
"name": "Итог I кв",
"children": {
"total": {
"name": "в тыс. рублей"
},
}
}
}
},
"fact_q2": {
# "key": None,
"name": "Фактические расходы за II кв",
"children": {
"up_apr": {
"key": None,
"name": "апрель",
"children": {
"apr": {
"name": "в тыс. рублей"
},
}
},
"up_may": {
"key": None,
"name": "май",
"children": {
"may": {
"name": "в тыс. рублей"
},
}
},
"up_jun": {
"key": None,
"name": "июнь",
"children": {
"jun": {
"name": "в тыс. рублей"
},
}
},
"up_total": {
"key": None,
"name": "Итог II кв",
"children": {
"total": {
"name": "в тыс. рублей"
},
}
}
}
},
"fact_q3": {
# "key": None,
"name": "Фактические расходы за III кв",
"children": {
"up_jul": {
"key": None,
"name": "июль",
"children": {
"jul": {
"name": "в тыс. рублей"
},
}
},
"up_aug": {
"key": None,
"name": "август",
"children": {
"aug": {
"name": "в тыс. рублей"
},
}
},
"up_sep": {
"key": None,
"name": "сентябрь",
"children": {
"sep": {
"name": "в тыс. рублей"
},
}
},
"up_total": {
"key": None,
"name": "Итог III кв",
"children": {
"total": {
"name": "в тыс. рублей"
},
}
}
}
},
"fact_q4": {
# "key": None,
"name": "Фактические расходы за IV кв",
"children": {
"up_oct": {
"key": None,
"name": "октябрь",
"children": {
"oct": {
"name": "в тыс. рублей"
},
}
},
"up_nov": {
"key": None,
"name": "ноябрь",
"children": {
"nov": {
"name": "в тыс. рублей"
},
}
},
"up_dec": {
"key": None,
"name": "декабрь",
"children": {
"dec": {
"name": "в тыс. рублей"
},
}
},
"up_total": {
"key": None,
"name": "Итог IV кв",
"children": {
"total": {
"name": "в тыс. рублей"
},
}
}
}
}
}
PALETTE = {
"green": {
"ROOT": "C2D59A",

View File

@ -24,6 +24,7 @@ from src.services.export_service.export_normalizers import (
from src.services.export_service.export_mappings import (
FORM1_COLORS,
FORM1_DEPTH_COLUMNS,
FORM2_DEPTH_AHR_SUB_COLUMNS,
FORM3_REPORT_COLUMNS,
SMETA_COLUMNS,
)
@ -39,6 +40,17 @@ ZIP_MEDIA_TYPE = "application/zip"
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
SHEET_NAMES = {
'AHR': 'АХР',
'CAP': 'КВП',
'OPER': 'Операц',
'AHR_LIMIT': 'АХР Лимиты',
'AHR_RENT': 'АХР Аренда',
'AHR_SECURITY': 'АХР Безопасность',
'AHR_UTILITY': 'АХР Коммунальные услуги',
'SMETA': 'Смета',
}
SKIP_SHEETS = ('OTCH9F',)
@dataclass(frozen=True)
class ExportSheetPlan:
@ -87,6 +99,21 @@ class ExportService:
value_serializer=self.value_serializer.to_excel_value,
colors_config=FORM1_COLORS["CAP"],
),
"AHR_RENT": DepthSheetWriter(
columns_dict=FORM2_DEPTH_AHR_SUB_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
),
"AHR_UTILITY": DepthSheetWriter(
columns_dict=FORM2_DEPTH_AHR_SUB_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
),
"AHR_SECURITY": DepthSheetWriter(
columns_dict=FORM2_DEPTH_AHR_SUB_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
),
}
self.form3_report_writer = TabularSheetWriter(
columns=FORM3_REPORT_COLUMNS,
@ -217,6 +244,9 @@ class ExportService:
workbook = self._create_workbook_with_metadata(form=form)
exported_sheets = 0
for plan in plans:
if not plan.validation_direction and plan.sheet_name in SKIP_SHEETS:
continue
self._validate_sheet_query_params(
form_type_code=form.form_type.code,
sheet=plan.sheet_name,
@ -229,7 +259,7 @@ class ExportService:
direction=plan.effective_direction,
sections=plan.effective_sections,
)
worksheet = workbook.create_sheet(title=self._safe_sheet_name(plan.sheet_name))
worksheet = workbook.create_sheet(title=self._safe_sheet_name(plan.sheet_name, plan.effective_direction))
self._write_sheet_rows(
worksheet=worksheet,
rows=rows,
@ -404,7 +434,15 @@ class ExportService:
return form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
@staticmethod
def _safe_sheet_name(value: str) -> str:
def _safe_sheet_name(value: str, direction: str | None = None) -> str:
if value in SHEET_NAMES:
sheet_name = SHEET_NAMES[value]
match direction:
case 'Support':
sheet_name = f'{sheet_name}'
case 'Development':
sheet_name = f'{sheet_name}_Р'
return sheet_name
safe = value
for char in ("[", "]", ":", "*", "?", "/", "\\"):
safe = safe.replace(char, "_")

View File

@ -0,0 +1,106 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form3_phase import Form3Phase
from src.db.models.form_type import FormType, FormTypeEnum
from src.repository.form3_phase_repository import Form3PhaseRepository
from src.core.errors import AccessDeniedException, ValidationsError
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate
class Form3PhaseService:
def __init__(self, db: AsyncSession):
self.db = db
self.f3p_repo = Form3PhaseRepository(db)
async def get_list(
self,
report_id: int | list[int],
phase_code: str | None = None,
) -> list[Form3Phase]:
return await self.f3p_repo.get_list(
report_id=report_id,
phase_code=phase_code,
)
async def get(
self, report_id: int, phase_code: str, user: AppUser
) -> Form3Phase | None:
return await self.f3p_repo.get(
report_id=report_id,
phase_code=phase_code,
)
def _validate_form3_phase(
self,
form_phase: Form3PhaseCreate | Form3PhaseUpdate,
) -> None:
errors = {}
if form_phase.column_keys:
form_type = FormType(code=FormTypeEnum.FORM_3)
for column_key in form_phase.column_keys:
section = column_key.split(".")
if section[0] not in form_type.section_list:
if "column_keys" in errors:
errors["column_keys"].append(f"Колонка {column_key} некорректна")
else:
errors["column_keys"] = [f"Колонка {column_key} некорректна"]
if errors:
raise ValidationsError(errors=errors)
async def create(
self,
report_id: int,
body: Form3PhaseCreate,
user: AppUser,
) -> Form3Phase:
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
self._validate_form3_phase(
form_phase=body,
)
return await self.f3p_repo.create(
report_id=report_id,
phase_code=body.phase_code,
role=body.role,
column_keys=body.column_keys,
opens_at=body.opens_at,
closes_at=body.closes_at,
)
async def update(
self,
report_id: int,
phase_code: str,
body: Form3PhaseUpdate,
user: AppUser,
) -> Form3Phase:
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
self._validate_form3_phase(
form_phase=body,
)
data = body.model_dump(exclude_unset=True)
if not data:
return await self.f3p_repo.get(
report_id=report_id,
phase_code=phase_code,
)
return await self.f3p_repo.update(
report_id=report_id,
phase_code=phase_code,
data=data,
)
async def delete(
self, report_id: int, phase_code: str, user: AppUser,
) -> bool:
return await self.f3p_repo.delete(
report_id=report_id,
phase_code=phase_code,
)

View File

@ -54,6 +54,7 @@ class OrgUnitService:
self,
title: str,
is_ssp: bool,
utc_offset: str,
user: AppUser,
) -> OrgUnit:
"""Создание нового ССП/РФ."""
@ -63,6 +64,7 @@ class OrgUnitService:
return await self.org_repo.create(
title=title,
is_ssp=is_ssp,
utc_offset=utc_offset,
)
async def update(

View File

@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.project import Project
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.core.errors import AccessDeniedException, ValidationException
@ -41,6 +42,10 @@ class ProjectService:
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
return project
async def get_instance(self, project_id: int, user: AppUser) -> Project | None:
org_unit_ids = await self._allowed_org_unit_ids(user)
return await self.project_repo.get_instance(project_id=project_id, org_unit_ids=org_unit_ids)
async def resolve_report_id(
self,
project_id: int,

View File

@ -0,0 +1,34 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.repository.rf_project_report_line_repository import RfProjectReportLineRepository
from src.db.models.app_user import AppUser
class RfProjectReportLineService:
def __init__(self, db: AsyncSession):
self.db = db
self.prl_repo = RfProjectReportLineRepository(db)
async def get(
self,
user: AppUser,
line_id: int,
) -> RfProjectReportLine | None:
return await self.prl_repo.get(line_id=line_id)
async def get_list(
self,
user: AppUser,
line_ids: list[int],
) -> list[RfProjectReportLine]:
return await self.prl_repo.get_list(line_ids=line_ids)
async def get_ids(
self,
project_report_id: int,
) -> list[int]:
return await self.prl_repo.get_ids(project_report_id=project_report_id)

View File

@ -0,0 +1,45 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report import RfProjectReport
from src.repository.rf_project_report_repository import RfProjectReportRepository
from src.db.models.app_user import AppUser
class RfProjectReportService:
def __init__(self, db: AsyncSession):
self.db = db
self.pr_repo = RfProjectReportRepository(db)
async def get(
self,
user: AppUser| None = None,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
load_project: bool = False,
) -> RfProjectReport | None:
return await self.pr_repo.get(
report_id=report_id,
year=year,
report_type=report_type,
project_id=project_id,
load_project=load_project,
)
async def get_list(
self,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
) -> list[RfProjectReport]:
return await self.pr_repo.get_list(
report_id=report_id,
year=year,
report_type=report_type,
project_id=project_id,
)

View File

@ -1,9 +1,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form4_project import Form4ProjectLevelEnum
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
from src.repository.form4_project_repository import Form4ProjectRepository
from src.repository.user_repository import UserRepository
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.repository.sheet_repository import SheetRepository
from src.repository.sheet_repository import SheetRepository, SheetValidationError
@ -11,6 +16,9 @@ class SheetService:
def __init__(self, db: AsyncSession):
self.db = db
self.sheet_repo = SheetRepository(db)
self.bf_repo = BudgetFormRepository(db)
self.user_repo = UserRepository(db)
self.f4_project_repo = Form4ProjectRepository(db)
async def get(
self,
@ -27,28 +35,6 @@ class SheetService:
sections=sections,
)
async def update_cell(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None,
line_id: int,
column: str,
value: str | int | float | None = None,
user: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
line_id=line_id,
column=column,
value=value,
user_id=user.id if user else None,
)
async def update_cells(
self,
form_id: int,
@ -124,6 +110,66 @@ class SheetService:
user_id=user.id if user else None,
)
async def add_program(
self,
form_id: int,
sheet: str,
name: str,
user: AppUser | None = None,
) -> tuple[int, list[tuple]]:
if user.role_id != UserRoleEnum.ADMIN:
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
form = await self.bf_repo.get(
budget_form_id=form_id,
org_unit=[ou.id for ou in user.org_units],
)
if not form:
raise SheetValidationError("Форма не найдена")
result = await self.sheet_repo.add_program(
form_id=form_id,
sheet=sheet,
name=name,
)
if result:
return (result[0][0], [tuple(r[1:]) for r in result])
return None
async def add_project(
self,
name: str,
program_id: int,
user: AppUser | None = None,
) -> tuple[int, list[tuple]]:
if user.role_id != UserRoleEnum.ADMIN:
program = await self.f4_project_repo.get(
project_id=program_id,
level=Form4ProjectLevelEnum.PROGRAM,
)
if not program:
raise SheetValidationError("Программа не найдена")
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
form = await self.bf_repo.get(
budget_form_id=program.form_id,
org_unit=[ou.id for ou in user.org_units],
)
if not form:
raise SheetValidationError("Программа не найдена")
result = await self.sheet_repo.add_project(
program_id=program_id,
name=name,
)
if result:
return (result[0][0], [tuple(r[1:]) for r in result])
return None
async def delete_line(
self,
form_id: int,

View File

@ -11,6 +11,7 @@ from src.repository.budget_form_repository import BudgetFormRepository
from src.core.errors import AccessDeniedException, ValidationException
from src.db.models import AppUser, UserRoleEnum, Vsp
from src.domain.schemas import VSPCreate, VSPUpdate
from src.repository.org_unit_repository import OrgUnitRepository
from src.repository.user_repository import UserRepository
from src.repository.vsp_repository import VSPRepository
@ -20,8 +21,8 @@ _info_close_sync_lock = asyncio.Lock()
class VSPService:
EXECUTOR_EDITABLE_FIELDS = {
"location_form",
"numbers",
"placement_type",
"staff_count",
"total_area",
"rent_contract_num",
"rent_end_date",
@ -31,6 +32,7 @@ class VSPService:
self.db = db
self.vsp_repo = VSPRepository(db)
self.form_repo = BudgetFormRepository(db)
self.org_unit_repo = OrgUnitRepository(db)
self.user_repo = UserRepository(db)
async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]:
@ -54,9 +56,9 @@ class VSPService:
ssp_ids: list[int] | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_max: int | None = None,
placement_type: str | None = None,
staff_count_min: int | None = None,
staff_count_max: int | None = None,
is_active: bool | None = None,
load_org_unit: bool = False,
with_count: bool = False,
@ -84,9 +86,9 @@ class VSPService:
ssp_ids=effective_ssp_ids,
open_date_start=open_date_start,
open_date_end=open_date_end,
location_form=location_form,
numbers_min=numbers_min,
numbers_max=numbers_max,
placement_type=placement_type,
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
is_active=is_active,
load_org_unit=load_org_unit,
with_count=with_count,
@ -118,16 +120,17 @@ class VSPService:
else:
ssp_ids = list(checking_ssps & set(ssp_ids))
await self._sync_closed_records()
return await self.vsp_repo.get_list(
ssp_ids=ssp_ids,
is_active=True,
close_date_is_null=True,
with_count=with_count,
)
async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp:
if not self._can_modify(user):
raise AccessDeniedException()
await self._ensure_active_branch(payload.branch_id)
try:
created = await self.vsp_repo.create(payload=payload, created_by=user.id, load_org_unit=load_org_unit)
if created.closed_at is not None and created.closed_at <= date.today():
@ -144,6 +147,8 @@ class VSPService:
raise ValidationException(description="Указанный номер уже существует")
if "chk_closed_after_opened" in message:
raise ValidationException(description="Дата закрытия не может быть меньше даты открытия")
if "chk_rent_date" in message:
raise ValidationException(description="Срок окончания договора аренды не может быть меньше даты открытия")
raise ValidationException(description="Некорректные данные для записи ВСП") from exc
async def export_xlsx(
@ -154,9 +159,9 @@ class VSPService:
ssp_ids: list[int] | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_max: int | None = None,
placement_type: str | None = None,
staff_count_min: int | None = None,
staff_count_max: int | None = None,
) -> tuple[io.BytesIO, str]:
data = await self.get_list(
user=user,
@ -165,9 +170,9 @@ class VSPService:
ssp_ids=ssp_ids,
open_date_start=open_date_start,
open_date_end=open_date_end,
location_form=location_form,
numbers_min=numbers_min,
numbers_max=numbers_max,
placement_type=placement_type,
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
is_active=None,
load_org_unit=True,
)
@ -188,8 +193,8 @@ class VSPService:
"open_date",
"close_date",
"notes",
"location_form",
"numbers",
"placement_type",
"staff_count",
"area",
"rent_contract_num",
"rent_end_date",
@ -215,8 +220,8 @@ class VSPService:
item.opened_at.isoformat() if item.opened_at else None,
item.closed_at.isoformat() if item.closed_at else None,
item.notes or None,
item.location_form or None,
item.numbers if item.numbers is not None else None,
item.placement_type or None,
item.staff_count if item.staff_count is not None else None,
float(item.total_area) if item.total_area is not None else None,
item.rent_contract_num or None,
item.rent_end_date.isoformat() if item.rent_end_date else None,
@ -245,6 +250,9 @@ class VSPService:
if not data:
return vsp
if "branch_id" in data:
await self._ensure_active_branch(data["branch_id"])
if user.role_id != UserRoleEnum.ADMIN:
ssp_ids = await self._get_scoped_ssp_ids(user=user)
if not ssp_ids or vsp.branch_id not in set(ssp_ids):
@ -266,6 +274,8 @@ class VSPService:
raise ValidationException(description="Указанный номер уже существует")
if "chk_closed_after_opened" in message:
raise ValidationException(description="Дата закрытия не может быть меньше даты открытия")
if "chk_rent_date" in message:
raise ValidationException(description="Срок окончания договора аренды не может быть меньше даты открытия")
raise ValidationException(description="Некорректные данные для записи ВСП") from exc
async def logical_delete(self, vsp_id: int, user: AppUser) -> bool:
@ -291,3 +301,10 @@ class VSPService:
return
await self.vsp_repo.deactivate_by_close_date(as_of=today)
_last_info_close_sync_run = today
async def _ensure_active_branch(self, branch_id: int | None) -> None:
if branch_id is None:
return
org_unit = await self.org_unit_repo.get(org_unit_id=branch_id)
if org_unit is None or not org_unit.is_active:
raise ValidationException(description="Указан неактивный или отсутствующий ССП/РФ")

View File

@ -30,3 +30,28 @@ def test_expense_item_get_executor(client, isp_tokens, auth_headers):
headers=auth_headers(isp_tokens),
)
assert response.status_code == 403
def test_expense_item_list(client, isp_tokens, auth_headers):
response = client.get(
"/api/v1/expense-item/",
headers=auth_headers(isp_tokens),
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert isinstance(result["result"], list)
assert len(result["result"]) != 0
def test_expense_item_list_r_start(client, isp_tokens, auth_headers):
response = client.get(
"/api/v1/expense-item/?r_start=true",
headers=auth_headers(isp_tokens),
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert isinstance(result["result"], list)
assert len(result["result"]) != 0
assert all([el["item_id"].startswith("R") for el in result["result"]])

View File

@ -84,16 +84,16 @@ def test_form_phases_delete_forbidden(client, isp_tokens):
def test_form_phases_create(client, admin_tokens):
body = {
"budget_form_id": 1,
"budget_form_id": 4,
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
"opens_at": "2026-01-01T00:00:00",
"closes_at": "2026-12-31T00:00:00",
}
response = client.post(
"/api/v1/stages/form/1",
"/api/v1/stages/form/4",
json=body,
headers=_auth_headers(admin_tokens),
)
@ -102,22 +102,31 @@ def test_form_phases_create(client, admin_tokens):
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
result = payload["result"]
assert isinstance(result, dict)
opens_at = result.pop("opens_at", None)
assert "+05" in opens_at
assert body.pop("opens_at") == opens_at.split("+")[0]
closes_at = result.pop("closes_at", None)
assert "+05" in closes_at
assert body.pop("closes_at") == closes_at.split("+")[0]
assert result == body
assert payload["result"] == body
def test_form_phases_update(client, admin_tokens):
body = {
"budget_form_id": 1,
"budget_form_id": 4,
"sheet": "AHR",
"phase_code": "test",
"role": "EXECUTOR_RF",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
"opens_at": "2026-01-01T00:00:00",
"closes_at": "2026-12-31T00:00:00",
}
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
"/api/v1/stages/form/4/AHR/test",
json=body,
headers=_auth_headers(admin_tokens),
)
@ -127,8 +136,15 @@ def test_form_phases_update(client, admin_tokens):
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
result = payload["result"]
assert isinstance(result, dict)
opens_at = result.pop("opens_at", None)
assert "+05" in opens_at
assert body.pop("opens_at") == opens_at.split("+")[0]
closes_at = result.pop("closes_at", None)
assert "+05" in closes_at
assert body.pop("closes_at") == closes_at.split("+")[0]
assert result == body
def test_form_phases_delete(client, admin_tokens):
@ -138,3 +154,165 @@ def test_form_phases_delete(client, admin_tokens):
)
assert response.status_code == 200
def test_form3_phases_list_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/project/2", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_form3_phases_list_filter_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/project/2", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
count = payload["count"]
response = client.get(
"/api/v1/stages/project/2?&phase_code=test", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
assert payload["count"] <= count
def test_form3_phases_create_forbidden(client, isp_tokens):
response = client.post(
"/api/v1/stages/project/2",
json={
"year": 2026,
"report_type": "LIMIT",
"phase_code": "CODE1",
"role": "DFIP",
"column_keys": [
"q1.adj_by_items",
"q1.adj_increase"
],
"opens_at": "2027-07-20T00:00:00",
"closes_at": "2027-09-21T00:00:00"
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form3_phases_update_forbidden(client, isp_tokens):
response = client.patch(
"/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026",
json={
"role": "DFIP",
"column_keys": [
"q1.adj_by_items",
"q1.adj_increase"
],
"opens_at": "2027-07-20T00:00:00",
"closes_at": "2027-09-21T00:00:00"
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form3_phases_delete_forbidden(client, isp_tokens):
response = client.delete(
"/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026",
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form3_phases_create(client, admin_tokens):
body = {
"year": 2026,
"report_type": "LIMIT",
"phase_code": "CODE1",
"role": "DFIP",
"column_keys": [
"q1.adj_by_items",
"q1.adj_increase"
],
"opens_at": "2027-07-20T00:00:00",
"closes_at": "2027-09-21T00:00:00"
}
response = client.post(
"/api/v1/stages/project/2",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
result = payload["result"]
assert isinstance(result, dict)
opens_at = result.pop("opens_at", None)
assert "+03" in opens_at
assert body.pop("opens_at") == opens_at.split("+")[0]
closes_at = result.pop("closes_at", None)
assert "+03" in closes_at
assert body.pop("closes_at") == closes_at.split("+")[0]
assert result == body
assert payload["result"] == body
def test_form3_phases_update(client, admin_tokens):
body = {
"role": "DFIP",
"column_keys": [
"q1.adj_by_items",
"q1.adj_increase"
],
"opens_at": "2027-07-20T00:00:00",
"closes_at": "2027-09-21T00:00:00"
}
response = client.patch(
"/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
result = payload["result"]
assert isinstance(result, dict)
opens_at = result.pop("opens_at", None)
assert "+03" in opens_at
assert body.pop("opens_at") == opens_at.split("+")[0]
closes_at = result.pop("closes_at", None)
assert "+03" in closes_at
assert body.pop("closes_at") == closes_at.split("+")[0]
body.update({'phase_code': 'RF_FILL_2026', 'report_type': 'LIMIT', 'year': 2026})
assert result == body
def test_form3_phases_delete(client, admin_tokens):
response = client.delete(
"/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200

View File

@ -161,6 +161,25 @@ def test_forms_add_form_success(client, admin_tokens, auth_headers):
assert response.status_code == 200
def test_forms_add_form_existing_unit(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/",
json={"year": 2026, "form_type_code": "FORM_2", "org_unit_ids": [1, 2]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
assert len(payload["result"]) == 2
assert "year" in payload["result"][0]
assert payload["result"][0]["year"] == 2026
assert "id" in payload["result"][1]
assert payload["result"][1]["id"] == 4
def test_forms_add_form_org_unit_not_found(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/",

View File

@ -78,6 +78,89 @@ def test_org_units_get_not_found(client, admin_tokens, auth_headers):
# assert payload["result"]["id"] == 1
def test_org_units_update_offset_smoke(client, admin_tokens, auth_headers):
old_offset = "+05"
new_offset = "+04"
response = client.get(
"/api/v1/stages/form/4?sheet=AHR&phase_code=test", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
phases = payload["result"]
assert isinstance(phases, list)
assert len(phases)
phase_old = phases[0]
response = client.patch(
"/api/v1/org-unit/2",
json={"utc_offset": new_offset},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["id"] == 2
assert payload["result"]["utc_offset"] == new_offset
response = client.get(
"/api/v1/stages/form/4?sheet=AHR&phase_code=test", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
phases = payload["result"]
assert isinstance(phases, list)
assert len(phases)
phase_new = phases[0]
assert new_offset in phase_new["opens_at"]
assert new_offset in phase_new["closes_at"]
assert phase_old["opens_at"].split(old_offset)[0] == phase_new["opens_at"].split(new_offset)[0]
assert phase_old["closes_at"].split(old_offset)[0] == phase_new["closes_at"].split(new_offset)[0]
def test_org_units_form3_update_offset_smoke(client, admin_tokens, auth_headers):
old_offset = "+03"
new_offset = "+04"
response = client.get(
"/api/v1/stages/project/2", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
phases = payload["result"]
assert isinstance(phases, list)
assert len(phases)
phase_old = phases[0]
response = client.patch(
"/api/v1/org-unit/1",
json={"utc_offset": new_offset},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["id"] == 1
assert payload["result"]["utc_offset"] == new_offset
response = client.get(
"/api/v1/stages/project/2", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
phases = payload["result"]
assert isinstance(phases, list)
assert len(phases)
phase_new = phases[0]
assert new_offset in phase_new["opens_at"]
assert new_offset in phase_new["closes_at"]
assert phase_old["opens_at"].split(old_offset)[0] == phase_new["opens_at"].split(new_offset)[0]
assert phase_old["closes_at"].split(old_offset)[0] == phase_new["closes_at"].split(new_offset)[0]
def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch(
"/api/v1/org-unit/99999",

View File

@ -48,6 +48,31 @@ def _add_line(client, admin_tokens, auth_headers, project_id: int) -> int:
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
def test_create_project_smoke(client, admin_tokens, auth_headers):
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": None, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "project_id" in payload
assert (project_id := payload["project_id"]) is not None
response = client.get(
f"/api/v1/projects/{project_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "id" in payload["result"]
assert payload["result"]["id"] == project_id
assert "years" in payload["result"]
assert isinstance(payload["result"]["years"], list)
assert len(payload["result"]["years"]) == 1
def test_projects_write_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens, auth_headers)

View File

@ -40,10 +40,10 @@ def test_vsp_dropdown_smoke(client, admin_tokens, auth_headers):
@pytest.mark.parametrize(
"params, count",
[
([("ssp_id", 2)], 1),
([("ssp_id", 2)], 2),
([("form_id", 1)], 0),
([("form_id", 2)], 1),
([("ssp_id", 2), ("form_id", 1)], 1),
([("form_id", 2)], 2),
([("ssp_id", 2), ("form_id", 1)], 2),
]
)
def test_vsp_dropdown_with_filter(client, admin_tokens, auth_headers, params, count):
@ -110,8 +110,8 @@ def test_vsp_create_with_all_fields(client, admin_tokens, auth_headers):
"close_date": "2025-12-31",
"approved_format": "Стандарт",
"notes": "Тестовое примечание",
"location_form": "Встроенное",
"numbers": 10,
"placement_type": "Аренда",
"staff_count": 10,
"area": 150.5,
"rent_contract_num": "Д-123/24",
"rent_end_date": "2025-12-31",
@ -127,8 +127,8 @@ def test_vsp_create_with_all_fields(client, admin_tokens, auth_headers):
assert payload["result"]["open_date"] == "2024-01-15"
assert payload["result"]["close_date"] == "2025-12-31"
assert payload["result"]["approved_format"] == "Стандарт"
assert payload["result"]["location_form"] == "Встроенное"
assert payload["result"]["numbers"] == 10
assert payload["result"]["placement_type"] == "Аренда"
assert payload["result"]["staff_count"] == 10
assert payload["result"]["area"] == 150.5
assert payload["result"]["rent_contract_num"] == "Д-123/24"
assert payload["result"]["rent_end_date"] == "2025-12-31"
@ -150,6 +150,36 @@ def test_vsp_create_forbidden_for_executor(client, isp_tokens, auth_headers):
assert "message" in payload
def test_vsp_create_with_inactive_ssp_fails(client, admin_tokens, auth_headers):
created_org = client.post(
"/api/v1/org-unit/",
json={"title": f"Inactive SSP {uuid.uuid4().hex[:6]}", "is_ssp": True},
headers=auth_headers(admin_tokens),
)
assert created_org.status_code == 200
org_unit_id = created_org.json()["result"]["id"]
deactivate = client.patch(
f"/api/v1/org-unit/{org_unit_id}",
json={"is_active": False},
headers=auth_headers(admin_tokens),
)
assert deactivate.status_code == 200
response = client.post(
"/api/v1/dict/info",
json={
"registration_number": _unique_reg_number(),
"address": "Should Fail",
"ssp_id": org_unit_id,
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert "message" in payload
def test_vsp_update_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
created = client.post(
@ -195,7 +225,33 @@ def test_vsp_delete_smoke(client, admin_tokens, auth_headers):
json={
"registration_number": reg_number,
"address": "To Be Deleted",
"ssp_id": 1,
"ssp_id": 2,
},
headers=auth_headers(admin_tokens),
)
assert created.status_code == 201
vsp_id = created.json()["result"]["id"]
response = client.delete(
f"/api/v1/dict/info/{vsp_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "Запись INFO удалена"
def test_vsp_delete_with_close_date_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
created = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "To Be Deleted with close_date",
"ssp_id": 2,
"open_date": "2026-01-01",
"close_date": "2030-01-01",
},
headers=auth_headers(admin_tokens),
)

View File

@ -3,9 +3,9 @@ INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_
(2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
SELECT setval('v3.app_user_id_seq', 2);
INSERT INTO v3.org_unit (id,title,is_active,is_ssp) VALUES
(1,'Test_SSP',true,true),
(2,'Test РФ',true,false);
INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES
(1,'Test_SSP',true,true,'+03'),
(2,'Test РФ',true,false,'+05');
SELECT setval('v3.org_unit_id_seq', 2);
INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
@ -24,9 +24,24 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or
SELECT setval('v3.budget_line_id_seq', 1);
INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03');
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03'),
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05');
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,location_form,numbers,rent_contract_num,rent_end_date) VALUES
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','встроенное помещение',NULL,'',NULL),
(2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','аренда',155,'12-45','2026-10-02');
SELECT setval('v3.vsp_id_seq', 2);
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','',NULL),
(2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','12-45','2027-10-02'),
(3,2,'3434','победы 12','формат','2026-06-01','субаренда', NULL,34,NULL,true,'2026-07-01 13:32:35.327097',false,'2026-06-29 15:53:50.236062',91,'3434',NULL,'2','примечание','567','2027-07-01');
SELECT setval('v3.vsp_id_seq', 3);
INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id) VALUES
(2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1);
SELECT setval('v3.project_id_seq', 3);
INSERT INTO v3.rf_project_report (id,project_id,"year",report_type,created_by,created_at,updated_by,updated_at) VALUES
(1,2,2026,'LIMIT',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'),
(2,2,2026,'CURRENT_EXPENSES',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138');
SELECT setval('v3.rf_project_report_id_seq', 3);
INSERT INTO v3.form3_phase (rf_project_report_id,phase_code,"role",column_keys,opens_at,closes_at) VALUES
(1,'RF_FILL_2026','EXECUTOR_RF','{{q1.adj_by_items,q1.adj_increase,q1.m1,q1.m2,q1.m3,q2.adj_by_items,q2.adj_increase,q2.m1,q2.m2,q2.m3,q3.adj_by_items,q3.adj_increase,q3.m1,q3.m2,q3.m3,q4.adj_by_items,q4.adj_increase,q4.m1,q4.m2,q4.m3,q4.spod}}','2030-01-01 03:00:00+03','2031-01-01 02:59:59+03'),
(2,'RF_FILL_2026','EXECUTOR_RF','{{q1.adj_by_items,q1.adj_increase,q1.m1,q1.m2,q1.m3,q2.adj_by_items,q2.adj_increase,q2.m1,q2.m2,q2.m3,q3.adj_by_items,q3.adj_increase,q3.m1,q3.m2,q3.m3,q4.adj_by_items,q4.adj_increase,q4.m1,q4.m2,q4.m3,q4.spod}}','2030-01-01 03:00:00+03','2031-01-01 02:59:59+03');

View File

@ -21,7 +21,7 @@ export const DictVspApi = {
responseType: "blob",
});
},
getDropdownVsp(params){
return api.get(`/dict/info/dropdown`, params).then((r) => r.data);
}
getDropdownVsp(params) {
return api.get(`/dict/info/dropdown`, { params }).then((r) => r.data);
},
};

34
web/src/api/projects.js Normal file
View File

@ -0,0 +1,34 @@
import api from "./client";
export const ProjectsApi = {
list: (params) => {
return api.get(`/projects`, { params }).then((r) => r.data);
},
get: (id) => {
return api.get(`/projects/${id}`).then((r) => r.data);
},
create: (data) => {
return api.post("/projects", data).then((r) => r.data);
},
update: (id, data) => {
return api.patch(`/projects/${id}`, data).then((r) => r.data);
},
delete: (id) => {
return api.delete(`/projects/${id}`).then((r) => r.data);
},
getTableData: (id, sheetName, year) => {
return api
.get(`/projects/${id}/report/${year}/${sheetName}`)
.then((r) => r.data);
},
export: (id, year, sheetName) => {
return api.get(`/export/project/${id}/report/${year}/${sheetName}`, {
responseType: "blob",
});
},
};

View File

@ -17,6 +17,7 @@ import { CircularProgress } from '@mui/material';
import NewTablePage from '../pages/NewTablePage.jsx';
import TablesTest from '../pages/TablesTest.jsx';
import NewFormTablePage from '../pages/NewFormTablePage.jsx'
import ProjectsPage from '../pages/ProjectsPage/ProjectsPage.jsx';
export const PrivateRoute = () => {
const { isAuthenticated, loading } = useAuth();
@ -37,8 +38,10 @@ export const AppRoutes = () => {
<Route element={<PrivateRoute />}>
<Route path="/" element={<TasksPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/projects" element={<ProjectsPage />}/>
<Route path="/forms" element={<FormsPage />} />
<Route path="/task/:taskId" element={<TaskPage />} />
<Route path="/project/:projectId" element={<TaskPage />} />
<Route path="/table/:tableId" element={<TablePage />} />
<Route path="/table-temp/:tableId" element={<TableTempPage />} />
<Route path="/admin_panel/users" element={<UsersPage />} />
@ -49,7 +52,7 @@ export const AppRoutes = () => {
<Route path="/new-table" element={<NewTablePage />} />
<Route path="/tables-new" element={<TablesTest />} />
<Route path="/tables/:form/:sheetName" element={<NewFormTablePage />} />
<Route path="/table/form/:formId/form-type/:formType/:sheetName/:direction" element={<NewFormTablePage />} />
<Route path="/table/form/:formId/form-type/:formType/:sheetName/:direction/:year" element={<NewFormTablePage />} />
</Route>
</Route>
</Routes>

View File

@ -4,7 +4,7 @@ import { NavLink } from 'react-router-dom';
import styled from '@emotion/styled';
import { TasksSvg, AdminPanelSvg, DictSvg } from '../common/icons/icons';
import { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants';
import { ROLES_NAME_ID } from '../../constants/constants';
const Switch = styled.div`
margin-left: 1rem;

View File

@ -1,144 +1,205 @@
import React, { useEffect, useState } from 'react';
import { useMemo } from 'react';
import styled from '@emotion/styled';
import React, { memo, useMemo, useCallback } from 'react';
import { useRealtime } from '../../contexts/RealtimeContext';
import { toast } from 'react-toastify';
const ContainerForText = styled.span`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
white-space: pre-wrap;
`;
// Стилизованный компонент для плашки блокировки
const LockBadge = styled.div`
position: absolute;
top: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
pointer-events: none;
z-index: 10;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: 0.3px;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
`;
// Функция для подсветки текста
const highlightText = (text, searchQuery) => {
if (!searchQuery || !text) return text;
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark
key={index}
style={{
backgroundColor: '#ffeb3b',
color: '#000',
fontWeight: 'bold',
padding: '0 2px',
borderRadius: '2px',
}}
>
{part}
</mark>
) : (
part
),
);
// Константы вне компонента
const BASE_CELL_STYLES = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '2px solid transparent',
padding: '8px',
};
// Функция для форматирования числа в финансовый формат
const formatNumber = (value) => {
if (value === null || value === undefined || value === '') return String(value || '');
const LOCKED_STYLES = {
border: '2px solid #e0e0e0',
backgroundColor: '#f5f5f5',
cursor: 'not-allowed',
opacity: 0.85,
};
const LOCK_BADGE_STYLES = {
position: 'absolute',
top: '2px',
right: '2px',
background: 'rgba(0, 0, 0, 0.7)',
color: 'white',
fontSize: '9px',
padding: '1px 4px',
borderRadius: '3px',
pointerEvents: 'none',
zIndex: 10,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
letterSpacing: '0.3px',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
};
const CONTAINER_STYLES = {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3,
overflow: 'hidden',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
const INTEGER_COLUMN_IDS = new Set([
'data.header.num_group',
'data.nomenclature_group_id',
'data.header.item_id',
'data.article_id',
'data.code_razdela',
]);
const getColorBrightness = (hexColor) => {
if (!hexColor) return 255;
const color = hexColor.replace('#', '');
let r, g, b;
if (color.length === 3) {
r = parseInt(color[0] + color[0], 16);
g = parseInt(color[1] + color[1], 16);
b = parseInt(color[2] + color[2], 16);
} else if (color.length === 6) {
r = parseInt(color.substring(0, 2), 16);
g = parseInt(color.substring(2, 4), 16);
b = parseInt(color.substring(4, 6), 16);
} else {
return 255;
}
return (0.299 * r + 0.587 * g + 0.114 * b);
};
const formatNumber = (value, asInteger = false) => {
if (value === null || value === undefined || value === '') return String(value || '');
const num = Number(value);
if (isNaN(num)) return String(value);
return num.toLocaleString('ru-RU', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
minimumFractionDigits: asInteger ? 0 : 1,
maximumFractionDigits: asInteger ? 0 : 1,
useGrouping: true,
});
};
const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color, isEditable }) => {
// Оптимизированная функция подсветки
const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
const cleanQueries = searchQueries.filter(Boolean);
if (cleanQueries.length === 0) return displayValue;
const searchStr = String(originalValue ?? displayValue);
const escapedQueries = cleanQueries.map(q => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
if (!regex.test(searchStr)) return displayValue;
const parts = searchStr.split(regex);
return parts.map((part, index) => {
if (!regex.test(part)) return part;
return React.createElement('mark', {
key: index,
style: {
backgroundColor: '#ffeb3b',
color: '#000',
fontWeight: 'bold',
padding: '0 2px',
borderRadius: '2px',
},
}, part);
});
};
const CellComponent = ({
cell,
row,
column,
onClick,
globalFilter,
columnFilter,
backgroundColor,
color,
isEditable,
isUpdating,
onCellNumberClick,
}) => {
const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`;
const isLocked = lockedCells.includes(cellKey);
const cellValue = cell.getValue();
let textValue = String(cellValue || '');
// Мемоизация значения
const { rawValue, displayValue, isNumeric } = useMemo(() => {
const value = cell.getValue();
const isNum = !isNaN(Number(value)) && value !== null && value !== undefined && value !== '';
let display = String(value || '');
const isNumeric = !isNaN(Number(cellValue)) && cellValue !== null && cellValue !== undefined && cellValue !== '';
if (isNumeric) {
textValue = formatNumber(cellValue);
}
if (isNum) {
const asInteger = INTEGER_COLUMN_IDS.has(column.id);
display = formatNumber(value, asInteger);
}
return { rawValue: value, displayValue: display, isNumeric: isNum };
}, [cell, column.id]);
const highlightedContent = useMemo(() => {
return highlightText(textValue, globalFilter);
}, [textValue, globalFilter]);
const searchQueries = [globalFilter, columnFilter];
return createHighlightedContent(rawValue, displayValue, searchQueries);
}, [rawValue, displayValue, globalFilter, columnFilter]);
// Базовые стили для заблокированной ячейки
const lockedStyles = isLocked ? {
border: '2px solid #e0e0e0',
backgroundColor: '#f5f5f5',
color: '#999999',
cursor: 'not-allowed',
opacity: 0.85,
} : {};
const textColor = useMemo(() => {
if (isLocked) return '#999999';
const brightness = getColorBrightness(backgroundColor);
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor, isLocked]);
// Если isEditable false, добавляем дополнительные стили
const editableStyles = !isEditable ? {
border: '2px solid #d3d3d3',
color: backgroundColor?.toLowerCase() === '#933634' ? '#ffffff' : '#525252',
cursor: 'not-allowed',
} : {};
const cellStyles = useMemo(() => ({
...BASE_CELL_STYLES,
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: textColor,
...(isLocked ? LOCKED_STYLES : {}),
}), [backgroundColor, isLocked, textColor]);
const lockBadge = useMemo(() => {
if (!isLocked) return null;
return <div style={LOCK_BADGE_STYLES}>🔒</div>;
}, [isLocked]);
const handleClick = useCallback(() => {
if (!isLocked && onClick) {
onClick();
}
}, [isLocked, onClick]);
return (
<div
className="cell"
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '2px solid transparent',
padding: '8px',
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: isLocked ? '#999999' : color,
...lockedStyles,
...editableStyles,
}}
onClick={isLocked ? undefined : onClick}
style={cellStyles}
onClick={handleClick}
>
<ContainerForText>{highlightedContent}</ContainerForText>
{isLocked && (
<LockBadge>
🔒
</LockBadge>
)}
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
{lockBadge}
</div>
);
};
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
// Возвращаем true если пропсы равны (не нужно перерендеривать)
return (
prevProps.cell.getValue() === nextProps.cell.getValue() &&
prevProps.row.id === nextProps.row.id &&
prevProps.column.id === nextProps.column.id &&
prevProps.globalFilter === nextProps.globalFilter &&
prevProps.columnFilter === nextProps.columnFilter &&
prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isEditable === nextProps.isEditable &&
prevProps.isUpdating === nextProps.isUpdating &&
prevProps.onCellNumberClick === nextProps.onCellNumberClick
);
});
export default Cell;

View File

@ -1,173 +1,217 @@
// EditCell.js - обновленная версия
import { useEffect, useRef, useLayoutEffect, useState } from 'react';
import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react';
import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils';
import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils';
import { useRealtime } from '../../contexts/RealtimeContext';
const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId }) => {
const [curValue, setCurValue] = useState(value);
const [displayValue, setDisplayValue] = useState(value);
const [isFormulaMode, setIsFormulaMode] = useState(false);
const EDITOR_STYLES = {
position: 'absolute',
zIndex: 12,
top: 0,
};
// Ключ для хранения формул в localStorage
const FORMULA_INDICATOR_STYLES = {
position: 'absolute',
top: '-20px',
left: '4px',
fontSize: '10px',
color: '#666',
background: '#f0f0f0',
padding: '2px 6px',
borderRadius: '3px',
pointerEvents: 'none',
};
const TEXTAREA_STYLES = {
width: '100%',
height: '3rem',
fieldSizing: 'content',
fontSize: 'inherit',
};
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
const { endEditing: contextEndEditing } = useRealtime();
const storageKey = `formula_${tableId}_${cellId}`;
const trFrag = refCell.current.offsetParent.offsetParent;
const tdFrag = refCell.current.offsetParent;
const left = tdFrag.offsetLeft;
const width = refCell.current.offsetParent.offsetWidth;
const translateY = trFrag.style.transform;
const [state, setState] = useState(() => ({
currentValue: value,
displayValue: value,
isFormulaMode: false,
}));
// Загружаем сохраненную формулу при монтировании
// Позиция ячейки
const [position, setPosition] = useState();
const textareaRef = useRef(null);
const refFinished = useRef(false);
// Загрузка сохраненной формулы
useEffect(() => {
const savedFormula = loadFromStorage(storageKey);
if (savedFormula && isFormula(savedFormula)) {
setIsFormulaMode(true);
setCurValue(savedFormula);
// Вычисляем и отображаем результат
try {
const formula = extractFormula(savedFormula);
const result = calculateFormula(formula);
setDisplayValue(formatNumber(result));
} catch (error) {
setDisplayValue('#ОШИБКА');
setState({
currentValue: savedFormula,
displayValue: formatNumber(result),
isFormulaMode: true,
});
} catch {
setState({
currentValue: savedFormula,
displayValue: '#ОШИБКА',
isFormulaMode: true,
});
}
} else {
setDisplayValue(value);
}
}, [storageKey, value]);
}, [storageKey]);
function useAutoResize(value) {
const ref = useRef(null);
// Вычисление позиции
useLayoutEffect(() => {
if (!refCell?.current) return;
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const td = refCell.current.offsetParent;
const tr = td?.offsetParent;
el.style.height = 'auto';
if (td && tr) {
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
translateY: tr.style.transform || '',
});
}
}, [refCell]);
// Auto-resize
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
const resize = () => {
el.style.minHeight = '3rem';
el.style.height = '3rem';
el.style.height = el.scrollHeight + 'px';
el.style.maxHeight = '180px';
}, [value]);
};
return ref;
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(el);
const ref = useAutoResize(value);
return () => observer.disconnect();
}, [state.currentValue]);
useEffect(() => {
if (ref.current && !disabled) {
ref.current.focus();
ref.current.setSelectionRange(
ref.current.value.length,
ref.current.value.length,
);
}
}, [disabled, ref]);
const handleSave = useCallback(() => {
const { currentValue } = state;
const handleSave = () => {
// Проверяем, является ли введенное значение формулой
if (isFormula(curValue)) {
// Сохраняем формулу в localStorage
saveToStorage(storageKey, curValue);
// Вычисляем и сохраняем результат
if (isFormula(currentValue)) {
saveToStorage(storageKey, currentValue);
try {
const formula = extractFormula(curValue);
const formula = extractFormula(currentValue);
const result = calculateFormula(formula);
const formattedResult = formatNumber(result);
setDisplayValue(formattedResult);
setState(prev => ({
...prev,
displayValue: formattedResult,
isFormulaMode: true,
}));
onChange?.(formattedResult);
setIsFormulaMode(true);
return;
} catch (error) {
setDisplayValue('#ОШИБКА');
onChange?.(curValue); // Сохраняем как текст, если ошибка
return;
} catch {
setState(prev => ({
...prev,
displayValue: '#ОШИБКА',
isFormulaMode: true,
}));
onChange?.(currentValue);
}
} else {
// Если это не формула, удаляем сохраненную формулу
localStorage.removeItem(storageKey);
setIsFormulaMode(false);
setDisplayValue(curValue);
onChange?.(curValue);
setState(prev => ({
...prev,
displayValue: currentValue,
isFormulaMode: false,
}));
onChange?.(currentValue);
}
};
}, [state, storageKey, onChange]);
const handleChangeValue = (event) => {
const input = event.target.value;
setCurValue(input);
// При редактировании показываем введенный текст
setDisplayValue(input);
};
// Функция для отображения значения в ячейке
const getDisplayValue = () => {
if (isFormulaMode && isFormula(curValue)) {
return displayValue;
const finishEditing = useCallback(() => {
if (refFinished.current) return;
refFinished.current = true;
if (!disabled) {
handleSave();
}
return displayValue;
};
const editingCell = table.getState().editingCell;
if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column);
}
}, [disabled, handleSave, table, contextEndEditing]);
useEffect(() => {
const handleClickOutside = (event) => {
const el = textareaRef.current;
if (el && !el.contains(event.target)) {
finishEditing();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [finishEditing]);
const handleChange = useCallback((e) => {
const value = e.target.value;
setState(prev => ({
...prev,
currentValue: value,
displayValue: value,
}));
}, []);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finishEditing();
}
}, [finishEditing]);
const editorStyles = useMemo(() => ({
...EDITOR_STYLES,
left: position?.left || 0,
width: position?.width || 0,
transform: position?.translateY || 0,
}), [position]);
const textareaStyles = useMemo(() => ({
...TEXTAREA_STYLES,
backgroundColor: state.isFormulaMode ? '#f8f9fa' : 'white',
border: state.isFormulaMode ? '2px solid #4a90d9' : undefined,
}), [state.isFormulaMode]);
return (
<tr>
<td>
<div
key="blockForEdit"
style={{
position: 'absolute',
left: left,
zIndex: 12,
width: width,
transform: translateY,
top: 0,
}}
>
{isFormulaMode && (
<div style={{
position: 'absolute',
top: '-20px',
left: '4px',
fontSize: '10px',
color: '#666',
background: '#f0f0f0',
padding: '2px 6px',
borderRadius: '3px',
pointerEvents: 'none'
}}>
fx
</div>
)}
<textarea
ref={ref}
disabled={disabled}
style={{
width: '100%',
height: 'auto',
fieldSizing: 'content',
fontSize: 'inherit',
backgroundColor: isFormulaMode ? '#f8f9fa' : 'white',
border: isFormulaMode ? '2px solid #4a90d9' : undefined,
}}
onChange={handleChangeValue}
value={curValue}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
// Закрываем редактирование
if (onChange) {
// Симулируем завершение редактирования
document.activeElement?.blur();
}
}
}}
placeholder={isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
/>
</div>
{position &&
<div style={editorStyles}>
{state.isFormulaMode && (
<div style={FORMULA_INDICATOR_STYLES}>fx</div>
)}
<textarea
ref={textareaRef}
disabled={disabled}
style={textareaStyles}
onChange={handleChange}
value={state.currentValue}
onKeyDown={handleKeyDown}
placeholder={state.isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
/>
</div>
}
</td>
</tr>
);
};
});
EditCell.displayName = 'EditCell';
export default EditCell;

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
const toLocalCoord = (value, scale) => value / scale;
const getPinnedClipLeft = (container, overlayParent, columnPinning, scale) => {
const pinnedIds = (columnPinning?.left || []).filter(Boolean);
if (!pinnedIds.length) return 0;
const parentRect = overlayParent.getBoundingClientRect();
let maxRight = 0;
for (const id of pinnedIds) {
const el = container.querySelector(`[data-col-select-id="${id}"]`);
if (!el) continue;
const rect = el.getBoundingClientRect();
maxRight = Math.max(maxRight, rect.right - parentRect.left);
}
return toLocalCoord(maxRight, scale);
};
const getOverlayRect = (container, overlayParent, selectedColumnId, columnPinning, scale) => {
const columnEl = container.querySelector(
`[data-col-select-id="${selectedColumnId}"]`,
);
if (!columnEl || !overlayParent) return null;
const bodyCell = container.querySelector(
`td[data-col-id="${selectedColumnId}"]`,
);
const parentRect = overlayParent.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const columnRect = columnEl.getBoundingClientRect();
const alignRect = bodyCell?.getBoundingClientRect() ?? columnRect;
let top = toLocalCoord(columnRect.top - parentRect.top, scale);
let left = toLocalCoord(alignRect.left - parentRect.left, scale);
let width = toLocalCoord(alignRect.width, scale);
const height = toLocalCoord(containerRect.bottom - columnRect.top, scale);
const pinnedIds = (columnPinning?.left || []).filter(Boolean);
const isSelectedPinned = pinnedIds.includes(selectedColumnId);
if (!isSelectedPinned && pinnedIds.length) {
const clipLeft = getPinnedClipLeft(container, overlayParent, columnPinning, scale);
const overlayRight = left + width;
if (overlayRight <= clipLeft) return null;
if (left < clipLeft) {
width = overlayRight - clipLeft;
left = clipLeft;
}
}
if (width <= 0 || height <= 0) return null;
return { top, left, width, height };
};
export const ColumnSelectionOverlay = ({
selectedColumnId,
containerRef,
scale = 1,
columnSizing,
columnPinning,
showColumnFilters,
}) => {
const [rect, setRect] = useState(null);
const [portalTarget, setPortalTarget] = useState(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const overlayParent = container.parentElement;
setPortalTarget(overlayParent);
}, [containerRef, selectedColumnId]);
useEffect(() => {
const container = containerRef.current;
const overlayParent = container?.parentElement;
if (!selectedColumnId || !container || !overlayParent) {
setRect(null);
return;
}
const updateRect = () => {
setRect(getOverlayRect(container, overlayParent, selectedColumnId, columnPinning, scale));
};
updateRect();
const scrollTargets = new Set([container]);
let scrollParent = container.parentElement;
for (let i = 0; i < 3 && scrollParent; i += 1) {
scrollTargets.add(scrollParent);
scrollParent = scrollParent.parentElement;
}
scrollTargets.forEach((target) => {
target.addEventListener('scroll', updateRect, { passive: true });
});
window.addEventListener('resize', updateRect);
const resizeObserver = new ResizeObserver(updateRect);
resizeObserver.observe(container);
resizeObserver.observe(overlayParent);
const header = container.querySelector('.custom-table-header');
if (header) {
resizeObserver.observe(header);
}
const tableBody = container.querySelector('.MuiTableBody-root');
if (tableBody) {
resizeObserver.observe(tableBody);
}
return () => {
scrollTargets.forEach((target) => {
target.removeEventListener('scroll', updateRect);
});
window.removeEventListener('resize', updateRect);
resizeObserver.disconnect();
};
}, [selectedColumnId, containerRef, scale, columnSizing, columnPinning, showColumnFilters]);
if (!rect || !portalTarget) return null;
return createPortal(
<div
style={{
position: 'absolute',
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
backgroundColor: 'rgba(88, 154, 47, 0.2)',
border: '1px solid #589A2F',
boxSizing: 'border-box',
pointerEvents: 'none',
zIndex: 1,
}}
/>,
portalTarget,
);
};

View File

@ -1,4 +1,3 @@
// components/RealtimeTable/index.js
import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react';
import { createPortal } from 'react-dom';
import {
@ -11,13 +10,14 @@ import { getTableColumns } from './tableColumns';
import { Cell, EditCell } from './index';
import { TableHead } from './TableHead/TableHead';
import SettingsPanel from './SettingPanel/SettingPanel';
import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelectionOverlay';
import { useColumnSettings } from './hooks/useColumnSettings';
import { useTableScale } from './hooks/useTableScale';
import { useHeaderPortal } from './hooks/useHeaderPortal';
import {
BASE_TABLE_CONFIG,
getTableHeadCellStyles,
getTableBodyCellProps,
getTablePaperStyles,
TABLE_ROW_HEIGHT,
} from './constants/tableConfig';
@ -25,14 +25,15 @@ import { useRealtime } from './contexts/RealtimeContext';
import { toast } from 'react-toastify';
import { CircularProgress } from '@mui/material';
import { additionVspRowTable } from './constants/addingRowConfig';
import { SelectVspModal } from './Modals/selectVspModal';
import { SelectVspModal } from './Modals/SelectVspModal';
import { getRowId } from './utils/rowUtils';
import { debounce } from '@mui/material';
const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction);
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year);
const [globalFilter, setGlobalFilter] = useState('');
const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumn, setSelectedColumn] = useState();
const [selectedColumnId, setSelectedColumnId] = useState();
const [rowSelection, setRowSelection] = useState({});
const [isLoadingData, setIsLoadingData] = useState(false);
const [isPending, startTransition] = useTransition();
@ -46,11 +47,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
return additionVspRowTable[formType].includes(sheetName);
}, [formType, sheetName]);
const handleGlobalFilterChange = (value) => {
startTransition(() => {
setGlobalFilter(value);
});
};
const handleGlobalFilterChange = useCallback(
debounce((value) => {
startTransition(() => {
setGlobalFilter(value);
});
}, 300),
[]
);
const {
isConnected,
@ -62,7 +66,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
addRow: contextAddRow,
deleteRow: contextDeleteRow,
startEditing: contextStartEditing,
endEditing: contextEndEditing,
} = useRealtime();
const {
@ -89,17 +92,27 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const [columnsConfig, setColumnsConfig] = useState(null);
const configCache = useRef(new Map());
useEffect(() => {
if (!formType || !sheetName) return;
const cacheKey = `${formType}_${sheetName}`;
if (configCache.current.has(cacheKey)) {
setColumnsConfig(configCache.current.get(cacheKey));
return;
}
const loadConfig = async () => {
try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
configCache.current.set(cacheKey, config.config);
setColumnsConfig(config.config);
} catch (error) {
console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
setColumnsConfig({ columns: [], colors: {} });
}
};
loadConfig();
}, [formType, sheetName]);
@ -113,6 +126,31 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
}));
}, [])
const handleColumnSelect = useCallback((columnId) => {
setSelectedColumnId((prev) => {
const next = prev === columnId ? undefined : columnId;
return next;
});
}, []);
useEffect(() => {
if (!selectedColumnId) return;
const handleDocumentClick = (e) => {
if (e.target.closest('[data-col-select-id]')) return;
if (e.target.closest('[data-pin-panel]')) return;
setSelectedColumnId(undefined);
};
document.addEventListener('mousedown', handleDocumentClick);
return () => document.removeEventListener('mousedown', handleDocumentClick);
}, [selectedColumnId]);
const handleCellUpdateError = useCallback((rowId, columnId, error) => {
console.error(`Error updating cell ${rowId}_${columnId}:`, error);
toast.error('Ошибка обновления ячейки');
}, []);
const columns = useMemo(() => {
if (!columnsConfig || !columnsConfig.columns) return [];
@ -123,164 +161,192 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
columnsConfig,
onCellUpdate: handleUpdateCell,
onCellNumberClick: handleClickRowCell,
onCellUpdateError: (rowId, columnId, error) => {
console.error(`Error updating cell ${rowId}_${columnId}:`, error);
toast.error('Ошибка обновления ячейки')
}
onCellUpdateError: handleCellUpdateError,
});
} catch (error) {
console.error('Error creating columns:', error);
return [];
}
}, [columnsConfig, handleUpdateCell]);
}, [columnsConfig]);
const selectedColumnIdRef = useRef(selectedColumnId);
useEffect(() => {
selectedColumnIdRef.current = selectedColumnId;
}, [selectedColumnId]);
const tableMeta = useMemo(() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
}), [handleUpdateCell]);
// Конфигурация таблицы
const tableConfig = useMemo(
() => ({
...BASE_TABLE_CONFIG,
columns,
data: data,
enableRowVirtualization: true,
enableColumnVirtualization: true,
rowVirtualizerInstanceRef: rowVirtualizerRef,
rowVirtualizerOptions: {
overscan: 30,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
estimateSize: () => TABLE_ROW_HEIGHT,
measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT,
},
onEditingCellChange: (cell) => {
if (cell) {
if (editingCell) return;
contextStartEditing?.(cell.row, cell.column);
} else {
contextEndEditing?.(editingCell.row, editingCell.column);
}
},
columnVirtualizerOptions: ({ table }) => ({
overscan: 10,
measureElement: (el) => {
if (!el) return 150;
const index = Number(el?.getAttribute?.('data-index'));
const isPinned = Boolean(el?.getAttribute?.('data-pinned'));
if (isPinned) {
const colId = table.getState().columnPinning.left[index];
const column = table.getColumn(colId);
return column?.getSize() ?? 150;
}
const allCols = [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns()]
return allCols[index]?.getSize() ?? 150;
const ROW_VIRTUALIZER_OPTIONS = {
overscan: 5,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
estimateSize: () => TABLE_ROW_HEIGHT,
measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT,
};
},
}),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnSizingChange: setColumnSizing,
onColumnPinningChange: setColumnPinning,
onGlobalFilterChange: handleGlobalFilterChange,
state: {
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
editingCell,
},
initialState: {
expanded: true,
},
meta: tableMeta,
muiTableHeadCellProps: {
sx: { boxSizing: 'border-box' },
},
muiTableBodyCellProps: getTableHeadCellStyles,
muiTableHeadProps: {
sx: {
display: 'table-header-group',
height: '1px',
minHeight: '1px',
maxHeight: '1px',
visibility: 'hidden',
},
},
muiTablePaperProps: getTablePaperStyles(),
muiTableContainerProps: {
ref: containerRef,
sx: {
position: 'relative',
contain: 'layout',
minHeight: '100%',
'& .MuiTable-root': { position: 'relative' },
},
},
muiTableHeadCellFilterTextFieldProps: {
placeholder: 'Поиск...',
size: 'small',
const createTableConfig = ({
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
contextStartEditing,
}) => ({
...BASE_TABLE_CONFIG,
columns,
data,
enableRowVirtualization: true,
enableColumnVirtualization: true,
rowVirtualizerInstanceRef: rowVirtualizerRef,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
onEditingCellChange: (cell) => {
if (cell && !editingCell) {
contextStartEditing?.(cell.row, cell.column);
}
},
columnVirtualizerOptions: ({ table }) => ({
overscan: 10,
measureElement: (el) => {
if (!el) return 150;
const index = Number(el?.getAttribute?.('data-index'));
const isPinned = Boolean(el?.getAttribute?.('data-pinned'));
if (isPinned) {
const colId = table.getState().columnPinning.left[index];
const column = table.getColumn(colId);
return column?.getSize() ?? 150;
}
const allCols = [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns()]
return allCols[index]?.getSize() ?? 150;
},
}),
[
columns,
data,
setColumnSizing,
setColumnPinning,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnSizingChange: setColumnSizing,
onColumnPinningChange: setColumnPinning,
onGlobalFilterChange: handleGlobalFilterChange,
state: {
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
containerRef,
tableMeta,
rowSelection,
editingCell
],
);
editingCell,
},
initialState: {
expanded: true,
},
meta: tableMeta,
muiTableHeadCellProps: {
sx: { boxSizing: 'border-box' },
},
muiTableBodyCellProps: getTableBodyCellProps,
muiTableHeadProps: {
sx: {
display: 'table-header-group',
height: '1px',
minHeight: '1px',
maxHeight: '1px',
visibility: 'hidden',
'& svg': {
height: '1px',
minHeight: '1px',
maxHeight: '1px',
visibility: 'hidden',
}
},
},
muiTablePaperProps: getTablePaperStyles(),
muiTableContainerProps: {
ref: containerRef,
sx: {
position: 'relative',
contain: 'layout',
minHeight: '100%',
'& .MuiTable-root': { position: 'relative' },
},
},
muiTableHeadCellFilterTextFieldProps: {
placeholder: 'Поиск...',
size: 'small',
},
});
// Конфигурация таблицы
const tableConfig = useMemo(() => createTableConfig({
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
contextStartEditing,
}), [
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
]);
const table = useMaterialReactTable(tableConfig);
useEffect(() => {
if (!dataEditingCells || !dataEditingCells.line_id) {
if (!dataEditingCells?.line_id) {
setEditingCell(null);
return;
}
if (editingCell) return;
const rowId = dataEditingCells.line_id;
const columnId = dataEditingCells.column;
try {
const row = table.getRow(rowId);
const column = table.getColumn(columnId);
const cell = row?.getVisibleCells().find(c => c.column.id === columnId);
if (cell) {
setEditingCell(cell);
// Отложить поиск до следующего тика
requestAnimationFrame(() => {
try {
const row = table.getRow(dataEditingCells.line_id);
const cell = row?.getVisibleCells().find(
c => c.column.id === dataEditingCells.column
);
if (cell) setEditingCell(cell);
} catch (error) {
console.error(error);
}
} catch (error) {
console.error(error);
toast.error('Ошибка редактирования ячейки');
}
}, [dataEditingCells, editingCell]);
});
}, [dataEditingCells, editingCell, table]);
const [isFirstLoad, setIsFirstLoad] = useState(true);
useEffect(() => {
if (table && data && columns.length !== 0) {
setIsLoadingData(true)
if (isFirstLoad && table && data && columns.length !== 0) {
setIsFirstLoad(false);
setIsLoadingData(true);
}
}, [data, columns, table])
useEffect(() => {
// Принудительно обновляем состояние закрепленных колонок
// если так не сделать при скролле будут пропадать закрепленные колонки
if (columnPinning && Object.keys(columnPinning).length > 0) {
const timer = setTimeout(() => {
setColumnPinning(prev => ({ ...prev }));
}, 100);
return () => clearTimeout(timer);
}
}, []);
}, [data, columns, table, isFirstLoad]);
const handleAddRow = useCallback(() => {
const allRows = table.getRowModel().rows;
@ -317,10 +383,22 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
setRowSelection({});
}, [rowSelection, table])
useEffect(() => {
return () => {
setData([]);
setRowSelection({});
setEditingCell(null);
if (rowVirtualizerRef.current) {
rowVirtualizerRef.current = null;
}
};
}, []);
return (
<>
<SettingsPanel
selectedColumn={selectedColumn}
selectedColumnId={selectedColumnId}
columnPinning={columnPinning}
table={table}
columns={columns}
onPinColumn={handlePinColumn}
@ -337,6 +415,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
formId={formId}
sheetName={sheetName}
direction={direction}
year={year}
isProject={formType == 'PROJECT'}
/>
<div style={tableWrapperStyle}>
@ -352,6 +432,15 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
>
<MaterialReactTable table={table} />
<ColumnSelectionOverlay
selectedColumnId={selectedColumnId}
containerRef={containerRef}
scale={sizeMult}
columnSizing={columnSizing}
columnPinning={columnPinning}
showColumnFilters={showColumnFilters}
/>
{isTableLoading && (
<div
style={{
@ -373,8 +462,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
<TableHead
table={table}
style={{ zIndex: (theme) => theme.zIndex.modal - 1 }}
onHeaderSelect={setSelectedColumn}
selectHeader={selectedColumn}
selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
/>,
@ -388,8 +477,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
onClose={() => setIsOpenModalSelectVsp(false)}
formId={formId}
onSelect={handleAddVspRow}
// vspOptions={}
// selectedVSP={}
title="Выбор ВСП"
/>
</>

View File

@ -18,10 +18,11 @@ import CollapsibleTree from '../../common/CollapsibleTree';
import ZoomSlider from './ZoomSlider';
import SearchComponent from '../../common/SearchComponent';
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
import { exportSheet } from '../../../utils/exportForm';
import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
const SettingPanel = ({
selectedColumn,
selectedColumnId,
columnPinning,
table,
columns,
onPinColumn,
@ -38,18 +39,24 @@ const SettingPanel = ({
formId,
sheetName,
direction,
year,
isProject,
}) => {
const [isPinned, setIsPinned] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [selectedColumnIds, setSelectedColumnIds] = useState();
const [showColumnFilters, setShowColumnFilters] = useState(false);
// TODO: console.log(!!!) везде посомтреть
useEffect(() => {
if (!selectedColumn) return;
const pinningColumn = table.getState().columnPinning.left;
const isPinned = pinningColumn.includes(selectedColumn.id);
setIsPinned(isPinned);
}, [selectedColumn, table]);
if (!selectedColumnId) {
setIsPinned(false);
return;
}
const pinningColumn = columnPinning?.left || table.getState().columnPinning.left || [];
const pinned = pinningColumn.includes(selectedColumnId);
setIsPinned(pinned);
}, [selectedColumnId, columnPinning, table]);
useEffect(() => {
setSelectedColumnIds(
@ -71,7 +78,16 @@ const SettingPanel = ({
const handleClickDeleteFormula = () => { };
const handleExport = async () => {
if (!formId || !sheetName || isExporting) return;
if (isExporting) return;
if (isProject) {
if (!formId || !sheetName || !year) return;
setIsExporting(true);
await exportSheetProject(formId, sheetName, year);
setIsExporting(false);
return;
}
if (!formId || !sheetName) return;
setIsExporting(true);
await exportSheet(formId, sheetName, direction);
setIsExporting(false);
@ -79,7 +95,7 @@ const SettingPanel = ({
return (
<>
<Panel>
<Panel data-pin-panel>
<Stack direction="row" sx={{ gap: '1.25rem' }}>
<GroupByObject title="Отображение">
{/* <ColorPickerButton onColorApply={addColorForCells} /> */}
@ -88,51 +104,48 @@ const SettingPanel = ({
<IconButton
variant="outlined"
data-active={isPinned}
onClick={() =>
disabled={!selectedColumnId}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => {
if (!selectedColumnId) {
console.warn('[ColumnPin] клик Pin: selectedColumnId пустой');
return;
}
console.log('[ColumnPin] клик Pin:', {
selectedColumnId,
isPinned,
action: isPinned ? 'unpin' : 'pin',
});
isPinned
? onUnpinColumn?.(selectedColumn.id)
: onPinColumn?.(selectedColumn.id)
}
? onUnpinColumn?.(selectedColumnId)
: onPinColumn?.(selectedColumnId);
}}
>
<Pin />
</IconButton>
</Tooltip>
</GroupByObject>
<Divider orientation="vertical" flexItem />
{!isProject &&
<>
<Divider orientation="vertical" flexItem />
<GroupByObject title="Строки">
<Tooltip title="Добавить строку">
<IconButton onClick={onAddRow} variant="outlined">
<Plus />
<AddLine />
</IconButton>
</Tooltip>
<GroupByObject title="Строки">
<Tooltip title="Добавить строку">
<IconButton onClick={onAddRow} variant="outlined">
<Plus />
<AddLine />
</IconButton>
</Tooltip>
<Tooltip title="Удалить строку">
<IconButton onClick={onDeleteRow} variant="outlined">
<Minus />
<RemoveLine />
</IconButton>
</Tooltip>
</GroupByObject>
{/* <Divider orientation="vertical" flexItem />
<GroupByObject title="Формулы">
<Tooltip title="Добавить формулу">
<IconButton onClick={handleClickAddFormula} variant="outlined">
<FormulaSettingPanel />
</IconButton>
</Tooltip>
<Tooltip title="Удалить формулу">
<IconButton onClick={handleClickDeleteFormula} variant="outlined">
<Minus />
<FormulaSettingPanel />
</IconButton>
</Tooltip>
</GroupByObject> */}
<Tooltip title="Удалить строку">
<IconButton onClick={onDeleteRow} variant="outlined">
<Minus />
<RemoveLine />
</IconButton>
</Tooltip>
</GroupByObject>
</>
}
<Divider orientation="vertical" flexItem />
<GroupByObject title="Столбцы">
{columns ? (

View File

@ -2,9 +2,12 @@ import React from 'react';
import ColumnResizer from './ColumnResizer';
import { columnPinningDefault } from '../constants/columnConfig';
const getPinnedColumnIds = (table) =>
(table.getState().columnPinning.left || []).filter(Boolean);
const getSortedColumns = (table) => {
const allLeafColumns = table.getAllLeafColumns();
const pinnedColumns = table.getState().columnPinning.left || [];
const pinnedColumns = getPinnedColumnIds(table);
return [...allLeafColumns].sort((a, b) => {
const aIsPinned = pinnedColumns.includes(a.id);
@ -46,7 +49,7 @@ const getPinnedColumnsWidth = (header, pinningColumn) => {
const calculateLeftOffset = (table, columnOrHeader) => {
const columnId = getColumnId(columnOrHeader);
const pinnedColumns = table.getState().columnPinning.left || [];
const pinnedColumns = getPinnedColumnIds(table);
const currentPinnedIndex = pinnedColumns.indexOf(columnId);
let leftOffset = 0;
@ -64,13 +67,13 @@ const calculateLeftOffset = (table, columnOrHeader) => {
};
const isColumnPin = (table, header) => {
const pinningColumn = table.getState().columnPinning.left;
const pinningColumn = getPinnedColumnIds(table);
const columnId = getColumnId(header);
const isPinned = pinningColumn.includes(columnId);
return isPinned;
};
const ColumnNumbersRow = ({ table }) => {
const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => {
const sortedColumns = getSortedColumns(table);
const tableState = table.getState();
const filteredColumns = sortedColumns.filter(
@ -95,27 +98,38 @@ const ColumnNumbersRow = ({ table }) => {
key={`col-number-${column.id}`}
column={column}
table={table}
selectedColumnId={selectedColumnId}
onColumnSelect={onColumnSelect}
/>
))}
</div>
);
};
const ColumnNumberCell = ({ column, table }) => {
const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) => {
const isPinned = isColumnPin(table, column);
const allLeafColumns = table.getAllLeafColumns();
const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id);
const leftOffset = calculateLeftOffset(table, column);
const selected = selectedColumnId === column.id;
return (
<div
key={`col-number-${column.id}`}
data-col-select-id={column.id}
onClick={(e) => {
e.stopPropagation();
onColumnSelect?.(column.id);
}}
onMouseDown={(e) => e.stopPropagation()}
style={{
width: column.getSize(),
minWidth: column.getSize(),
maxWidth: column.getSize(),
padding: '2px 6px',
textAlign: 'center',
backgroundColor: '#e5e5e5',
backgroundColor: selected ? '#589A2F' : '#FBFBFB',
color: selected ? '#ffffff' : '#5E5E5E',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
@ -125,7 +139,9 @@ const ColumnNumberCell = ({ column, table }) => {
borderBottom: 'none',
position: isPinned ? 'sticky' : 'relative',
left: isPinned ? leftOffset : 'auto',
zIndex: isPinned ? 20 : 'auto',
zIndex: selected ? 16 : isPinned ? 20 : 'auto',
cursor: 'pointer',
userSelect: 'none',
}}
>
{originalIndex}
@ -133,9 +149,9 @@ const ColumnNumberCell = ({ column, table }) => {
);
};
const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => {
const HeaderCell = ({ header, table, onClick, onChangeWidth }) => {
const column = header.column;
const pinningColumn = table.getState().columnPinning.left || [];
const pinningColumn = getPinnedColumnIds(table);
const isGroup = header.subHeaders?.length > 0;
const isPinned = isColumnPin(table, header);
const leftOffset = calculateLeftOffset(table, header);
@ -148,7 +164,7 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => {
const backgroundColor = customBgColor
? customBgColor
: (hightlight ? '#cbccce' : 'rgba(243, 244, 246, 1)');
: 'rgba(243, 244, 246, 1)';
const handleChangeWidth = (deltaWidth) => {
const size = header.getSize();
@ -164,7 +180,7 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => {
}
// Для незакрепленных колонок текст должен начинаться после всех закрепленных
// Получаем общую ширину всех закрепленных колонок
const pinnedColumns = table.getState().columnPinning.left || [];
const pinnedColumns = getPinnedColumnIds(table);
let totalPinnedWidth = 0;
for (const pinnedId of pinnedColumns) {
const pinnedCol = table.getColumn(pinnedId);
@ -188,6 +204,7 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => {
textAlign: 'center',
fontWeight: 600,
fontSize: '12px',
lineHeight: 1,
backgroundColor: backgroundColor,
color: '#424242',
boxSizing: 'border-box',
@ -296,10 +313,10 @@ const FilterCell = ({ column, table }) => {
onChange={(e) => handleFilterChange(e.target.value)}
style={{
width: '100%',
padding: '4px 6px',
fontSize: '11px',
padding: '0.25rem 0.375rem',
fontSize: '0.6875rem',
border: '1px solid #ddd',
borderRadius: '4px',
borderRadius: '0.25rem',
backgroundColor: 'white',
}}
>
@ -313,23 +330,30 @@ const FilterCell = ({ column, table }) => {
);
case 'range': {
// Для диапазона можно реализовать два поля
const [min, max] = column.getFilterValue() || ['', ''];
return (
<div style={{ display: 'flex', gap: '4px' }}>
<div style={{ display: 'flex', gap: '0.25rem' }}> {/* 4px */}
<input
type="number"
placeholder="От"
value={min}
onChange={(e) => column.setFilterValue([e.target.value, max])}
style={{ width: '50%', padding: '4px', fontSize: '11px' }}
style={{
width: '50%',
padding: '0.25rem',
fontSize: '0.6875rem'
}}
/>
<input
type="number"
placeholder="До"
value={max}
onChange={(e) => column.setFilterValue([min, e.target.value])}
style={{ width: '50%', padding: '4px', fontSize: '11px' }}
style={{
width: '50%',
padding: '0.25rem',
fontSize: '0.6875rem'
}}
/>
</div>
);
@ -344,10 +368,10 @@ const FilterCell = ({ column, table }) => {
placeholder="Поиск..."
style={{
width: '100%',
padding: '4px 6px',
fontSize: '11px',
padding: '0.25rem 0.375rem',
fontSize: '0.6875rem',
border: '1px solid #ddd',
borderRadius: '4px',
borderRadius: '0.25rem',
boxSizing: 'border-box',
}}
/>
@ -379,13 +403,15 @@ const FilterCell = ({ column, table }) => {
export const TableHead = ({
table,
onHeaderSelect,
selectHeader,
selectedColumnId,
onColumnSelect,
onChangeWidth,
isLoadingData,
}) => {
const handleHeaderClick = (header) => {
onHeaderSelect(header);
if (!header.subHeaders?.length && header.column?.id) {
onColumnSelect?.(header.column.id);
}
};
if (!isLoadingData) {
@ -402,9 +428,15 @@ export const TableHead = ({
}}
>
{/* Строка с номерами колонок */}
<ColumnNumbersRow table={table} />
<ColumnNumbersRow
table={table}
selectedColumnId={selectedColumnId}
onColumnSelect={onColumnSelect}
/>
{table.getState().showColumnFilters && <FilterRow table={table} />}
{table.getState().showColumnFilters && (
<FilterRow table={table} />
)}
{/* Существующие строки заголовков */}
{table.getHeaderGroups().map((headerGroup) => (
@ -413,9 +445,6 @@ export const TableHead = ({
<React.Fragment key={header.id}>
<HeaderCell
header={header}
hightlight={
selectHeader ? header.id == selectHeader.id : false
}
table={table}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,555 +0,0 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from '../columnColors';
export const config = {
"config":{
"colors":{
"kod_razdela_kod_razdela":{
"color_type":whiteColumn,
"accessorKey":"kod_razdela_kod_razdela"
},
"id_stati_id_stati":{
"color_type":whiteColumn,
"accessorKey":"id_stati_id_stati"
},
"id_gruppy_nomenklatury_id_gruppy_nomenklatury":{
"color_type":whiteColumn,
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury"
},
"naimenovanie_naimenovanie":{
"color_type":whiteColumn,
"accessorKey":"naimenovanie_naimenovanie"
},
"korrektirovka_limita_po_statyam_smety":{
"color_type":whiteColumn,
"accessorKey":"korrektirovka_limita_po_statyam_smety"
},
"field_uvelichenie_smety":{
"color_type":whiteColumn,
"accessorKey":"field_uvelichenie_smety"
},
"field_za_yanvar":{
"color_type":whiteColumn,
"accessorKey":"field_za_yanvar"
},
"field_za_fevral":{
"color_type":whiteColumn,
"accessorKey":"field_za_fevral"
},
"field_itogo_i_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_i_kvartal"
},
"field_za_aprel":{
"color_type":whiteColumn,
"accessorKey":"field_za_aprel"
},
"field_za_may":{
"color_type":whiteColumn,
"accessorKey":"field_za_may"
},
"field_itogo_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_ii_kvartal"
},
"field_za_iyul":{
"color_type":whiteColumn,
"accessorKey":"field_za_iyul"
},
"field_za_avgust":{
"color_type":whiteColumn,
"accessorKey":"field_za_avgust"
},
"field_itogo_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iii_kvartal"
},
"field_za_oktyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_oktyabr"
},
"field_za_noyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_noyabr"
},
"field_spod":{
"color_type":whiteColumn,
"accessorKey":"field_spod"
},
"field_itogo_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iv_kvartal"
},
"itogo_itogo":{
"color_type":whiteColumn,
"accessorKey":"itogo_itogo"
},
"fakt_zamart":{
"color_type":whiteColumn,
"accessorKey":"fakt_zamart"
},
"fakt_zaiyun":{
"color_type":whiteColumn,
"accessorKey":"fakt_zaiyun"
},
"fakt_zasentyabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zasentyabr"
},
"fakt_zadekabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zadekabr"
},
"ekonomiya_pereraskhod_ekonomiya_pereraskhod":{
"color_type":whiteColumn,
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod"
},
"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal"
}
},
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela",
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela_kod_razdela",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID статьи",
"accessorKey":"id_stati",
"columns":[
{
"header":"ID статьи",
"accessorKey":"id_stati_id_stati",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury",
"columns":[
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Наименование",
"accessorKey":"naimenovanie",
"columns":[
{
"header":"Наименование",
"accessorKey":"naimenovanie_naimenovanie",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Корректировка лимита",
"accessorKey":"korrektirovka_limita",
"columns":[
{
"header":"по статьям сметы *",
"accessorKey":"korrektirovka_limita_po_statyam_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"",
"accessorKey":"field",
"columns":[
{
"header":"увеличение сметы **",
"accessorKey":"field_uvelichenie_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ЯНВАРЬ",
"accessorKey":"field_za_yanvar",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ФЕВРАЛЬ",
"accessorKey":"field_za_fevral",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого I квартал",
"accessorKey":"field_itogo_i_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АПРЕЛЬ",
"accessorKey":"field_za_aprel",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за МАЙ",
"accessorKey":"field_za_may",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого II квартал",
"accessorKey":"field_itogo_ii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ИЮЛЬ",
"accessorKey":"field_za_iyul",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АВГУСТ",
"accessorKey":"field_za_avgust",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого III квартал",
"accessorKey":"field_itogo_iii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ОКТЯБРЬ",
"accessorKey":"field_za_oktyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за НОЯБРЬ",
"accessorKey":"field_za_noyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"СПОД",
"accessorKey":"field_spod",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого IV квартал",
"accessorKey":"field_itogo_iv_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого",
"accessorKey":"itogo",
"columns":[
{
"header":"Итого",
"accessorKey":"itogo_itogo",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Факт",
"accessorKey":"fakt",
"columns":[
{
"header":"заМАРТ",
"accessorKey":"fakt_zamart",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заИЮНЬ",
"accessorKey":"fakt_zaiyun",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заСЕНТЯБРЬ",
"accessorKey":"fakt_zasentyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заДЕКАБРЬ",
"accessorKey":"fakt_zadekabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod",
"columns":[
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
]
}
}

View File

@ -1,577 +0,0 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from '../columnColors';
export const config = {
"config":{
"colors":{
"kod_razdela_kod_razdela":{
"color_type":whiteColumn,
"accessorKey":"kod_razdela_kod_razdela"
},
"id_stati_id_stati":{
"color_type":whiteColumn,
"accessorKey":"id_stati_id_stati"
},
"id_gruppy_nomenklatury_id_gruppy_nomenklatury":{
"color_type":whiteColumn,
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury"
},
"naimenovanie_naimenovanie":{
"color_type":whiteColumn,
"accessorKey":"naimenovanie_naimenovanie"
},
"korrektirovka_plana_po_statyam_smety":{
"color_type":whiteColumn,
"accessorKey":"korrektirovka_plana_po_statyam_smety"
},
"field_uvelichenie_smety":{
"color_type":whiteColumn,
"accessorKey":"field_uvelichenie_smety"
},
"field_za_yanvar":{
"color_type":whiteColumn,
"accessorKey":"field_za_yanvar"
},
"field_za_fevral":{
"color_type":whiteColumn,
"accessorKey":"field_za_fevral"
},
"field_itogo_i_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_i_kvartal"
},
"field_za_aprel":{
"color_type":whiteColumn,
"accessorKey":"field_za_aprel"
},
"field_za_may":{
"color_type":whiteColumn,
"accessorKey":"field_za_may"
},
"field_itogo_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_ii_kvartal"
},
"field_za_iyul":{
"color_type":whiteColumn,
"accessorKey":"field_za_iyul"
},
"field_za_avgust":{
"color_type":whiteColumn,
"accessorKey":"field_za_avgust"
},
"field_itogo_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iii_kvartal"
},
"field_za_oktyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_oktyabr"
},
"field_za_noyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_noyabr"
},
"field_spod":{
"color_type":whiteColumn,
"accessorKey":"field_spod"
},
"field_itogo_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iv_kvartal"
},
"itogo_korrektirovka_itogo_korrektirovka":{
"color_type":whiteColumn,
"accessorKey":"itogo_korrektirovka_itogo_korrektirovka"
},
"fakt_zamart":{
"color_type":whiteColumn,
"accessorKey":"fakt_zamart"
},
"fakt_zaiyun":{
"color_type":whiteColumn,
"accessorKey":"fakt_zaiyun"
},
"fakt_zasentyabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zasentyabr"
},
"fakt_zadekabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zadekabr"
},
"ekonomiya_pereraskhod_ekonomiya_pereraskhod":{
"color_type":whiteColumn,
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod"
},
"smeta_raskhodov_po_razvitiyu_na_ii_kvartal_smeta_raskhodov_po_razvitiyu_na_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_ii_kvartal_smeta_raskhodov_po_razvitiyu_na_ii_kvartal"
},
"itogo_skorrek_smeta_itogo_skorrek_smeta":{
"color_type":whiteColumn,
"accessorKey":"itogo_skorrek_smeta_itogo_skorrek_smeta"
},
"smeta_raskhodov_po_razvitiyu_na_iii_kvartal_smeta_raskhodov_po_razvitiyu_na_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iii_kvartal_smeta_raskhodov_po_razvitiyu_na_iii_kvartal"
},
"smeta_raskhodov_po_razvitiyu_na_iv_kvartal_smeta_raskhodov_po_razvitiyu_na_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iv_kvartal_smeta_raskhodov_po_razvitiyu_na_iv_kvartal"
}
},
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela",
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela_kod_razdela",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID статьи",
"accessorKey":"id_stati",
"columns":[
{
"header":"ID статьи",
"accessorKey":"id_stati_id_stati",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury",
"columns":[
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Наименование",
"accessorKey":"naimenovanie",
"columns":[
{
"header":"Наименование",
"accessorKey":"naimenovanie_naimenovanie",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Корректировка плана",
"accessorKey":"korrektirovka_plana",
"columns":[
{
"header":"по статьям сметы *",
"accessorKey":"korrektirovka_plana_po_statyam_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"",
"accessorKey":"field",
"columns":[
{
"header":"увеличение сметы **",
"accessorKey":"field_uvelichenie_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ЯНВАРЬ",
"accessorKey":"field_za_yanvar",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ФЕВРАЛЬ",
"accessorKey":"field_za_fevral",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого I квартал",
"accessorKey":"field_itogo_i_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АПРЕЛЬ",
"accessorKey":"field_za_aprel",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за МАЙ",
"accessorKey":"field_za_may",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого II квартал",
"accessorKey":"field_itogo_ii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ИЮЛЬ",
"accessorKey":"field_za_iyul",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АВГУСТ",
"accessorKey":"field_za_avgust",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого III квартал",
"accessorKey":"field_itogo_iii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ОКТЯБРЬ",
"accessorKey":"field_za_oktyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за НОЯБРЬ",
"accessorKey":"field_za_noyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"СПОД",
"accessorKey":"field_spod",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого IV квартал",
"accessorKey":"field_itogo_iv_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого корректировка",
"accessorKey":"itogo_korrektirovka",
"columns":[
{
"header":"Итого корректировка",
"accessorKey":"itogo_korrektirovka_itogo_korrektirovka",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Факт",
"accessorKey":"fakt",
"columns":[
{
"header":"заМАРТ",
"accessorKey":"fakt_zamart",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заИЮНЬ",
"accessorKey":"fakt_zaiyun",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заСЕНТЯБРЬ",
"accessorKey":"fakt_zasentyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заДЕКАБРЬ",
"accessorKey":"fakt_zadekabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod",
"columns":[
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Смета расходов по развитию на II квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_ii_kvartal",
"columns":[
{
"header":"Смета расходов по развитию на II квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_ii_kvartal_smeta_raskhodov_po_razvitiyu_na_ii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого скоррек. смета",
"accessorKey":"itogo_skorrek_smeta",
"columns":[
{
"header":"Итого скоррек. смета",
"accessorKey":"itogo_skorrek_smeta_itogo_skorrek_smeta",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Смета расходов по развитию на III квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iii_kvartal",
"columns":[
{
"header":"Смета расходов по развитию на III квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iii_kvartal_smeta_raskhodov_po_razvitiyu_na_iii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Смета расходов по развитию на IV квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iv_kvartal",
"columns":[
{
"header":"Смета расходов по развитию на IV квартал",
"accessorKey":"smeta_raskhodov_po_razvitiyu_na_iv_kvartal_smeta_raskhodov_po_razvitiyu_na_iv_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
]
}
}

View File

@ -1,555 +0,0 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from '../columnColors';
export const config = {
"config":{
"colors":{
"kod_razdela_kod_razdela":{
"color_type":whiteColumn,
"accessorKey":"kod_razdela_kod_razdela"
},
"id_stati_id_stati":{
"color_type":whiteColumn,
"accessorKey":"id_stati_id_stati"
},
"id_gruppy_nomenklatury_id_gruppy_nomenklatury":{
"color_type":whiteColumn,
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury"
},
"naimenovanie_naimenovanie":{
"color_type":whiteColumn,
"accessorKey":"naimenovanie_naimenovanie"
},
"korrektirovka_limita_po_statyam_smety":{
"color_type":whiteColumn,
"accessorKey":"korrektirovka_limita_po_statyam_smety"
},
"field_uvelichenie_smety":{
"color_type":whiteColumn,
"accessorKey":"field_uvelichenie_smety"
},
"field_za_yanvar":{
"color_type":whiteColumn,
"accessorKey":"field_za_yanvar"
},
"field_za_fevral":{
"color_type":whiteColumn,
"accessorKey":"field_za_fevral"
},
"field_itogo_i_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_i_kvartal"
},
"field_za_aprel":{
"color_type":whiteColumn,
"accessorKey":"field_za_aprel"
},
"field_za_may":{
"color_type":whiteColumn,
"accessorKey":"field_za_may"
},
"field_itogo_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_ii_kvartal"
},
"field_za_iyul":{
"color_type":whiteColumn,
"accessorKey":"field_za_iyul"
},
"field_za_avgust":{
"color_type":whiteColumn,
"accessorKey":"field_za_avgust"
},
"field_itogo_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iii_kvartal"
},
"field_za_oktyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_oktyabr"
},
"field_za_noyabr":{
"color_type":whiteColumn,
"accessorKey":"field_za_noyabr"
},
"field_spod":{
"color_type":whiteColumn,
"accessorKey":"field_spod"
},
"field_itogo_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"field_itogo_iv_kvartal"
},
"itogo_itogo":{
"color_type":whiteColumn,
"accessorKey":"itogo_itogo"
},
"fakt_zamart":{
"color_type":whiteColumn,
"accessorKey":"fakt_zamart"
},
"fakt_zaiyun":{
"color_type":whiteColumn,
"accessorKey":"fakt_zaiyun"
},
"fakt_zasentyabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zasentyabr"
},
"fakt_zadekabr":{
"color_type":whiteColumn,
"accessorKey":"fakt_zadekabr"
},
"ekonomiya_pereraskhod_ekonomiya_pereraskhod":{
"color_type":whiteColumn,
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod"
},
"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal"
},
"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal":{
"color_type":whiteColumn,
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal"
}
},
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela",
"columns":[
{
"header":"Код раздела",
"accessorKey":"kod_razdela_kod_razdela",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID статьи",
"accessorKey":"id_stati",
"columns":[
{
"header":"ID статьи",
"accessorKey":"id_stati_id_stati",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury",
"columns":[
{
"header":"ID группы номенклатуры",
"accessorKey":"id_gruppy_nomenklatury_id_gruppy_nomenklatury",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Наименование",
"accessorKey":"naimenovanie",
"columns":[
{
"header":"Наименование",
"accessorKey":"naimenovanie_naimenovanie",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Корректировка лимита",
"accessorKey":"korrektirovka_limita",
"columns":[
{
"header":"по статьям сметы *",
"accessorKey":"korrektirovka_limita_po_statyam_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"",
"accessorKey":"field",
"columns":[
{
"header":"увеличение сметы **",
"accessorKey":"field_uvelichenie_smety",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ЯНВАРЬ",
"accessorKey":"field_za_yanvar",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ФЕВРАЛЬ",
"accessorKey":"field_za_fevral",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого I квартал",
"accessorKey":"field_itogo_i_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АПРЕЛЬ",
"accessorKey":"field_za_aprel",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за МАЙ",
"accessorKey":"field_za_may",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого II квартал",
"accessorKey":"field_itogo_ii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ИЮЛЬ",
"accessorKey":"field_za_iyul",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за АВГУСТ",
"accessorKey":"field_za_avgust",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого III квартал",
"accessorKey":"field_itogo_iii_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за ОКТЯБРЬ",
"accessorKey":"field_za_oktyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"за НОЯБРЬ",
"accessorKey":"field_za_noyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"СПОД",
"accessorKey":"field_spod",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого IV квартал",
"accessorKey":"field_itogo_iv_kvartal",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Итого",
"accessorKey":"itogo",
"columns":[
{
"header":"Итого",
"accessorKey":"itogo_itogo",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Факт",
"accessorKey":"fakt",
"columns":[
{
"header":"заМАРТ",
"accessorKey":"fakt_zamart",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заИЮНЬ",
"accessorKey":"fakt_zaiyun",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заСЕНТЯБРЬ",
"accessorKey":"fakt_zasentyabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"заДЕКАБРЬ",
"accessorKey":"fakt_zadekabr",
"size":150,
"filterFn":"contains",
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod",
"columns":[
{
"header":"экономия (+) перерасход (-)",
"accessorKey":"ekonomiya_pereraskhod_ekonomiya_pereraskhod",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на II квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_ii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на III квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iii_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
},
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"columns":[
{
"header":"Лимит затрат / остаток лимита затрат на IV квартал",
"accessorKey":"limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal_limit_zatrat_ostatok_limita_zatrat_na_iv_kvartal",
"size":150,
"filterFn":"contains"
}
],
"muiTableHeadCellProps":{
"sx":{
"backgroundColor":"#C2D69B",
"color":"#000000"
}
}
}
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,869 @@
import {
greenColumn,
whiteColumn,
orangeColumn,
yellowColumn,
redColumn,
blueColumn,
} from "../columnColors";
export const config = {
config: {
colors: {
"data.section_code": {
color_type: greenColumn,
accessorKey: "data.section_code",
},
"data.name": {
color_type: greenColumn,
accessorKey: "data.name",
},
"data.plan.support.q1": {
color_type: greenColumn,
accessorKey: "data.plan.support.q1",
},
"data.plan.support.q2": {
color_type: greenColumn,
accessorKey: "data.plan.support.q2",
},
"data.plan.support.q3": {
color_type: greenColumn,
accessorKey: "data.plan.support.q3",
},
"data.plan.support.q4": {
color_type: greenColumn,
accessorKey: "data.plan.support.q4",
},
"data.plan.support.year": {
color_type: greenColumn,
accessorKey: "data.plan.support.year",
},
"data.plan.development.q1": {
color_type: greenColumn,
accessorKey: "data.plan.development.q1",
},
"data.plan.development.q2": {
color_type: greenColumn,
accessorKey: "data.plan.development.q2",
},
"data.plan.development.q3": {
color_type: greenColumn,
accessorKey: "data.plan.development.q3",
},
"data.plan.development.q4": {
color_type: greenColumn,
accessorKey: "data.plan.development.q4",
},
"data.plan.development.year": {
color_type: greenColumn,
accessorKey: "data.plan.development.year",
},
itogo_2026_field_field_field: {
color_type: greenColumn,
accessorKey: "itogo_2026_field_field_field",
},
"data.approved.support.q1": {
color_type: greenColumn,
accessorKey: "data.approved.support.q1",
},
"data.approved.support.q2": {
color_type: greenColumn,
accessorKey: "data.approved.support.q2",
},
"data.approved.support.q3": {
color_type: greenColumn,
accessorKey: "data.approved.support.q3",
},
"data.approved.support.q4": {
color_type: greenColumn,
accessorKey: "data.approved.support.q4",
},
"data.approved.support.year": {
color_type: greenColumn,
accessorKey: "data.approved.support.year",
},
"data.approved.development.q1": {
color_type: greenColumn,
accessorKey: "data.approved.development.q1",
},
"data.approved.development.q2": {
color_type: greenColumn,
accessorKey: "data.approved.development.q2",
},
"data.approved.development.q3": {
color_type: greenColumn,
accessorKey: "data.approved.development.q3",
},
"data.approved.development.q4": {
color_type: greenColumn,
accessorKey: "data.approved.development.q4",
},
"data.approved.development.year": {
color_type: greenColumn,
accessorKey: "data.approved.development.year",
},
"data.fact.support.q1": {
color_type: greenColumn,
accessorKey: "data.fact.support.q1",
},
"data.fact.support.q2": {
color_type: greenColumn,
accessorKey: "data.fact.support.q2",
},
"data.fact.support.q3": {
color_type: greenColumn,
accessorKey: "data.fact.support.q3",
},
"data.fact.support.q4": {
color_type: greenColumn,
accessorKey: "data.fact.support.q4",
},
"data.fact.support.year": {
color_type: greenColumn,
accessorKey: "data.fact.support.year",
},
"data.fact.development.q1": {
color_type: greenColumn,
accessorKey: "data.fact.development.q1",
},
"data.fact.development.q2": {
color_type: greenColumn,
accessorKey: "data.fact.development.q2",
},
"data.fact.development.q3": {
color_type: greenColumn,
accessorKey: "data.fact.development.q3",
},
"data.fact.development.q4": {
color_type: greenColumn,
accessorKey: "data.fact.development.q4",
},
"data.fact.development.year": {
color_type: greenColumn,
accessorKey: "data.fact.development.year",
},
"data.corrected.support.q2": {
color_type: greenColumn,
accessorKey: "data.corrected.support.q2",
},
"data.corrected.support.q3": {
color_type: greenColumn,
accessorKey: "data.corrected.support.q3",
},
"data.corrected.support.q4": {
color_type: greenColumn,
accessorKey: "data.corrected.support.q4",
},
"data.corrected.development.q2": {
color_type: greenColumn,
accessorKey: "data.corrected.development.q2",
},
"data.corrected.development.q3": {
color_type: greenColumn,
accessorKey: "data.corrected.development.q3",
},
"data.corrected.development.q4": {
color_type: greenColumn,
accessorKey: "data.corrected.development.q4",
},
},
columns: [
{
header: "№ п/п",
accessorKey: "pp",
columns: [
{
header: "",
accessorKey: "pp_field",
columns: [
{
header: "",
accessorKey: "data.section_code",
columnLetter: "B",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Перечень расходов и затрат",
accessorKey: "perechen_raskhodov_i_zatrat",
columns: [
{
header: "",
accessorKey: "perechen_raskhodov_i_zatrat_field",
columns: [
{
header: "",
accessorKey: "data.name",
columnLetter: "C",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Расходы, планируемые ССП на 2026 год",
accessorKey: "raskhody_planiruemye_ssp_na_2026_god",
columns: [
{
header: "Поддержка",
accessorKey: "raskhody_planiruemye_ssp_na_2026_god_podderzhka",
columns: [
{
header: "I квартал",
accessorKey:
"raskhody_planiruemye_ssp_na_2026_god_podderzhka_i_kvartal",
accessorKey: "data.plan.support.q1",
columnLetter: "D",
size: 150,
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "II квартал",
accessorKey:
"raskhody_planiruemye_ssp_na_2026_god_podderzhka_ii_kvartal",
accessorKey: "data.plan.support.q2",
columnLetter: "E",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey:
"raskhody_planiruemye_ssp_na_2026_god_podderzhka_iii_kvartal",
accessorKey: "data.plan.support.q3",
columnLetter: "F",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.plan.support.q4",
columnLetter: "G",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "ИТОГОПоддержка",
accessorKey: "data.plan.support.year",
columnLetter: "H",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Развитие",
accessorKey: "raskhody_planiruemye_ssp_na_2026_god_razvitie",
columns: [
{
header: "I квартал",
accessorKey: "data.plan.development.q1",
columnLetter: "I",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "II квартал",
accessorKey: "data.plan.development.q2",
columnLetter: "J",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.plan.development.q3",
columnLetter: "K",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.plan.development.q4",
columnLetter: "L",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "ИТОГОРазвитие",
accessorKey: "data.plan.development.year",
columnLetter: "M",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Утвержденные базовые расходы на 2026 год",
accessorKey: "utverzhdennye_bazovye_raskhody_na_2026_god",
columns: [
{
header: "Поддержка",
accessorKey:
"utverzhdennye_bazovye_raskhody_na_2026_god_podderzhka",
columns: [
{
header: "I квартал",
accessorKey: "data.approved.support.q1",
columnLetter: "P",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "II квартал",
accessorKey: "data.approved.support.q2",
columnLetter: "Q",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.approved.support.q3",
columnLetter: "R",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.approved.support.q4",
columnLetter: "S",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "ИТОГОПоддержка",
accessorKey: "data.approved.support.year",
columnLetter: "T",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Развитие",
accessorKey: "utverzhdennye_bazovye_raskhody_na_2026_god_razvitie",
columns: [
{
header: "I квартал",
accessorKey: "data.approved.development.q1",
columnLetter: "U",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "II квартал",
accessorKey: "data.approved.development.q2",
columnLetter: "V",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.approved.development.q3",
columnLetter: "W",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.approved.development.q4",
columnLetter: "X",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "ИТОГОРазвитие",
accessorKey: "data.approved.development.year",
columnLetter: "Y",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Фактические расходы за 2026 год",
accessorKey: "fakticheskie_raskhody_za_2026_god",
columns: [
{
header: "Поддержка",
accessorKey: "fakticheskie_raskhody_za_2026_god_podderzhka",
columns: [
{
header: "I квартал",
accessorKey: "data.fact.support.q1",
columnLetter: "AB",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "II квартал",
accessorKey: "data.fact.support.q2",
columnLetter: "AC",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.fact.support.q3",
columnLetter: "AD",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.fact.support.q4",
columnLetter: "AE",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "ИТОГОПоддержка",
accessorKey: "data.fact.support.year",
columnLetter: "AF",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Развитие",
accessorKey: "fakticheskie_raskhody_za_2026_god_razvitie",
columns: [
{
header: "I квартал",
accessorKey: "data.fact.development.q1",
columnLetter: "AG",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "II квартал",
accessorKey: "data.fact.development.q2",
columnLetter: "AH",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.fact.development.q3",
columnLetter: "AI",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.fact.development.q4",
columnLetter: "AJ",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "ИТОГОРазвитие",
accessorKey: "data.fact.development.year",
columnLetter: "AK",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Скорректированная смета расходов на 2026 год",
accessorKey: "skorrektirovannaya_smeta_raskhodov_na_2026_god",
columns: [
{
header: "Поддержка",
accessorKey:
"skorrektirovannaya_smeta_raskhodov_na_2026_god_podderzhka",
columns: [
{
header: "II квартал",
accessorKey: "data.corrected.support.q2",
columnLetter: "AN",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.corrected.support.q3",
columnLetter: "AO",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.corrected.support.q4",
columnLetter: "AP",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "Развитие",
accessorKey:
"skorrektirovannaya_smeta_raskhodov_na_2026_god_razvitie",
columns: [
{
header: "II квартал",
accessorKey: "data.corrected.development.q2",
columnLetter: "AQ",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "III квартал",
accessorKey: "data.corrected.development.q3",
columnLetter: "AR",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
{
header: "IV квартал",
accessorKey: "data.corrected.development.q4",
columnLetter: "AS",
size: 150,
filterFn: "contains",
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#BFBFBF",
color: "#000000",
},
},
},
],
},
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,18 @@ export const getTableHeadCellStyles = () => ({
},
});
export const getTableBodyCellProps = ({ column, table }) => {
const isPinned = (table.getState().columnPinning?.left || []).includes(column.id);
return {
'data-col-id': column.id,
sx: {
...getTableHeadCellStyles().sx,
...(isPinned && { zIndex: 5 }),
},
};
};
export const getTablePaperStyles = () => ({
sx: {
boxShadow: "none",

View File

@ -25,17 +25,29 @@ export const RealtimeProvider = ({
formId,
sheetName,
direction,
year,
userId,
isProject = false,
}) => {
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
const wsUrl = `${(process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || "ws://localhost:8000").replace(/^http/, "ws")}/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`;
const wsUrl =
`${(
process.env.REACT_APP_API_URL ||
process.env.REACT_APP_API_URL ||
process.env.REACT_APP_ROOT_PATH ||
"ws://localhost:8000"
).replace(/^http/, "ws")}` +
(!isProject
? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`
: `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`);
//для ячеек которые редактируются другими пользователями
const [lockedCells, setLockedCells] = useState([]);
const handleMessage = useCallback((data) => {
// Обработка ошибок
if (data.error) {
console.log(data.error.message)
toast.error("Server error:" + data.error.message || '');
console.log(data.error.message);
toast.error("Server error:" + data.error.message || "");
onErrorRef.current?.(data.error);
return;
}
@ -81,7 +93,7 @@ export const RealtimeProvider = ({
}
}, []);
const { isConnected, sendMessage, disconnect, lastError } = useWebSocket(
const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = useWebSocket(
wsUrl,
handleMessage,
);
@ -145,13 +157,26 @@ export const RealtimeProvider = ({
}
}, [isConnected]);
const addCommonField = (message) => {
if (isProject) {
message.report_type = sheetName;
message.project_id = formId;
message.year = year;
} else {
message.sheet = sheetName;
message.form_id = formId;
message.direction = direction;
}
return message;
};
// Функция для начала редактирования ячейки
const startEditing = useCallback(
async (row, column) => {
const columnId = column.id;
const rowId = row.id;
if (!isConnected) {
if (!isConnectedRef) {
onErrorRef.current?.("WebSocket не подключен");
return false;
}
@ -183,7 +208,7 @@ export const RealtimeProvider = ({
const columnId = column.id;
const rowId = row.id;
if (!isConnected) {
if (!isConnectedRef) {
onErrorRef.current?.("WebSocket не подключен");
return false;
}
@ -213,7 +238,7 @@ export const RealtimeProvider = ({
async (row, column, value) => {
const columnId = column.id;
const rowId = row.id;
if (!isConnected) {
if (!isConnectedRef) {
onErrorRef.current?.("WebSocket не подключен");
return false;
}
@ -224,6 +249,7 @@ export const RealtimeProvider = ({
event: "cell_updated",
data: {
line_id: lineId,
line_id_code: row.id,
column: colId,
value: value,
},
@ -241,7 +267,7 @@ export const RealtimeProvider = ({
const addRow = useCallback(
async (rowData) => {
if (!isConnected) {
if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации");
return false;
}
@ -249,10 +275,9 @@ export const RealtimeProvider = ({
const message = {
event: "row_added",
data: rowData,
sheet: sheetName,
form_id: formId,
direction: direction,
};
addCommonField(message);
return sendMessage(message);
},
[isConnected, sendMessage, sheetName, formId, direction],
@ -260,7 +285,7 @@ export const RealtimeProvider = ({
const deleteRow = useCallback(
async (rowId) => {
if (!isConnected) {
if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации");
return false;
}
@ -268,10 +293,8 @@ export const RealtimeProvider = ({
const message = {
event: "row_deleted",
data: { row_id: rowId },
sheet: sheetName,
form_id: formId,
direction: direction,
};
addCommonField(message);
return sendMessage(message);
},

View File

@ -41,6 +41,7 @@ export const useColumnSettings = (tableKey) => {
}, []);
const handleUnpinColumn = useCallback((colId) => {
if (!colId) return;
if (columnPinningDefault.left.includes(colId)) return;
setColumnPinning((prev) => ({
...prev,

View File

@ -4,8 +4,9 @@ import { useRealtime } from "../contexts/RealtimeContext";
import { deleteRow, insertCell, updateCells } from "../utils/cellUtils";
import { toast } from "react-toastify";
import { isVspNewRow, isVspRow } from "../utils/rowUtils";
import { ProjectsApi } from "../../../api/projects";
const useRealtimeData = (formId, sheetName, direction) => {
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const [data, setData] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
@ -77,7 +78,10 @@ const useRealtimeData = (formId, sheetName, direction) => {
try {
setIsLoading(true);
let res;
if (direction !== null) {
if (formType == "PROJECT") {
res = await ProjectsApi.getTableData(formId, sheetName, year);
} else if (direction !== null) {
res = await FormsSheetApi.get(formId, sheetName, {
params: { direction: direction },
});

View File

@ -107,30 +107,16 @@ export const useWebSocket = (url, onMessage) => {
return false;
}, []);
const login = useCallback(
(token) => {
if (!token) {
console.warn("No token provided for login");
return false;
}
return sendMessage({
event: "user_login",
data: { token },
});
},
[sendMessage],
);
useEffect(() => {
connect();
return () => disconnect();
}, [connect, disconnect]);
return {
isConnected: isConnectedRef,
isConnectedRef,
isConnected,
error,
sendMessage,
login,
disconnect,
};
};

View File

@ -1,10 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { columns as mockColumns } from '../../mocks/mockTable';
import { config } from './constants/FORM_2/AHR';
import { useParams } from 'react-router';
import { useRealtime } from './contexts/RealtimeContext';
function EditCellPortal({
const EditCellPortal = React.memo(({
cell,
table,
EditCell,
@ -12,85 +11,71 @@ function EditCellPortal({
onSaveEnd,
onError,
isEditable
}) {
}) => {
const { formId, formType, sheetName, direction } = useParams();
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
const ref = useRef(null);
const [refTbody, setRefTbody] = useState(false);
const [refTbody, setRefTbody] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const isDisabled = false;
const { endEditing: contextEndEditing } = useRealtime();
useEffect(() => {
if (ref.current) {
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
setRefTbody(tbodyRef);
if (tbodyRef && tbodyRef !== refTbody) {
setRefTbody(tbodyRef);
}
}
}, []);
const handleChange = async (val) => {
const handleChange = useCallback( (val) => {
table.setEditingCell(null);
try {
if (table.options.meta?.updateCell) {
const success = await table.options.meta.updateCell(
const success = table.options.meta.updateCell(
cell.row,
cell.column,
val
);
if (success) {
console.log('Cell updated successfully via WebSocket');
} else {
if (!success) {
console.error('Failed to update cell via WebSocket');
const errorMsg = 'Не удалось сохранить изменение';
onError?.(errorMsg);
onError?.('Не удалось сохранить изменение');
}
}
} catch (error) {
console.error('Error updating cell:', error);
onError?.(error.message);
}
};
}, [table, cell, onError]);
const editCellProps = useMemo(() => ({
refCell: ref,
cell,
value: cell.getValue(),
disabled: !isEditable,
onChange: handleChange,
tableId: tableKey,
table,
cellId: `${cell.row.id}_${cell.column.id}`,
}), [cell, isEditable, handleChange, tableKey, table]);
const portalContent = useMemo(() => {
if (!refTbody || isSaving) return null;
return createPortal(
<EditCell {...editCellProps} />,
refTbody
);
}, [refTbody, isSaving, editCellProps, EditCell]);
return (
<>
<div
ref={ref}
style={{
width: '100%',
height: '100%',
}}
/>
{refTbody && !isSaving &&
createPortal(
<EditCell
refCell={ref}
cell={cell}
value={cell.getValue()}
disabled={!isEditable}
onChange={handleChange}
tableId={tableKey}
cellId={cell.row.id + '_' + cell.column.id}
/>,
refTbody,
)}
{isSaving && (
createPortal(
<div style={{
position: 'absolute',
background: 'rgba(0,0,0,0.5)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 1000
}}>
Saving...
</div>,
refTbody
)
)}
<div ref={ref} style={{ width: '100%', height: '100%' }} />
{portalContent}
</>
);
}
});
export const getTableColumns = ({
Cell,
@ -100,43 +85,105 @@ export const getTableColumns = ({
onCellNumberClick,
}) => {
const columnColors = { ...columnsConfig.colors };
const columns = [...columnsConfig.columns];
const columns = structuredClone(columnsConfig.columns);
const processColumns = (columns, EditCell, Cell) => {
const cellPropsCache = new WeakMap();
const editPropsCache = new WeakMap();
const getCachedCellProps = (row, column, table) => {
const key = `${row.id}_${column.id}`;
if (!cellPropsCache.has(row)) {
cellPropsCache.set(row, {});
}
const rowCache = cellPropsCache.get(row);
if (rowCache[key]) {
const cached = rowCache[key];
const currentGlobalFilter = table.getState().globalFilter;
const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
if (cached.globalFilter === currentGlobalFilter &&
cached.columnFilter === currentColumnFilter) {
return cached;
}
}
const globalFilter = table.getState().globalFilter;
const columnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
const props = {
globalFilter,
columnFilter,
isEditable: row.original?.row_type === 'INPUT' || false,
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}`,
};
rowCache[key] = props;
return props;
};
const getCachedEditProps = (row, column, table) => {
const key = `${row.id}_${column.id}`;
if (!editPropsCache.has(row)) {
editPropsCache.set(row, {});
}
const rowCache = editPropsCache.get(row);
if (rowCache[key]) {
return rowCache[key];
}
const props = {
isEditable: row.original?.row_type === 'INPUT' || false,
};
rowCache[key] = props;
return props;
};
// Оптимизированная функция processColumns
const processColumns = (columns) => {
columns.forEach((col) => {
col.Cell = ({ cell, table, column, row }) => (
<Cell
row={row}
column={column}
cell={cell}
globalFilter={
table.getState().globalFilter
}
isEditable={row.original?.row_type === 'INPUT' || false}
backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]}
color={columnColors[column.id]?.color_type?.['COLOR']}
isUpdating={table.options.meta?.updatingCells?.[`${row.id}_${column.id}`]}
/>
);
col.Cell = ({ cell, table, column, row }) => {
const props = getCachedCellProps(row, column, table);
col.Edit = ({ cell, table, row, column }) => (
<EditCellPortal
cell={cell}
table={table}
EditCell={EditCell}
onError={onCellUpdateError}
isEditable={row.original?.row_type === 'INPUT' || false}
/>
);
const cellProps = useMemo(() => ({
row,
column,
cell,
...props,
}), [row.id, column.id, cell.getValue(), props._hash]);
return <Cell {...cellProps} />;
};
col.Edit = ({ cell, table, row, column }) => {
const props = getCachedEditProps(row, column, table);
const editComponent = useMemo(() => (
<EditCellPortal
cell={cell}
table={table}
EditCell={EditCell}
onError={onCellUpdateError}
isEditable={props.isEditable}
/>
), [cell, table, EditCell, onCellUpdateError, props.isEditable]);
return editComponent;
};
// Рекурсивно обрабатываем вложенные колонки
if (col.columns?.length) {
processColumns(col.columns, EditCell, Cell);
processColumns(col.columns);
}
});
};
processColumns(columns, EditCell, Cell);
processColumns(columns);
const rowNumber = {
accessorKey: 'sort_order',
@ -144,9 +191,11 @@ export const getTableColumns = ({
size: 50,
enableEditing: false,
Cell: ({ cell, row }) => {
return <button onClick={() => { onCellNumberClick(row.id) }}>
{cell.getValue() + ' '}
</button>
const handleClick = useCallback(() => {
onCellNumberClick(row.id);
}, [row.id, onCellNumberClick]);
return <button onClick={handleClick}>{cell.getValue() + ' '}</button>;
},
};

View File

@ -1,3 +1,5 @@
import { getDataRowId } from "./rowUtils";
export function setNewValueToData(data, colId, rowId, value) {
let targetObject = data;
if (rowId && rowId.length) {
@ -44,6 +46,29 @@ export function setNewValueToData(data, colId, rowId, value) {
export function updateCells(data, newRows) {
const updatedRows = JSON.parse(JSON.stringify(data));
function deepMerge(target, source) {
if (!source || typeof source !== "object") {
return source;
}
if (!target || typeof target !== "object") {
target = {};
}
Object.keys(source).forEach((key) => {
if (
source[key] &&
typeof source[key] === "object" &&
!Array.isArray(source[key])
) {
target[key] = deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
});
return target;
}
newRows.forEach((update) => {
const [rowType, depth, sortOrder, newData] = update;
@ -61,7 +86,7 @@ export function updateCells(data, newRows) {
Object.keys(newData).forEach((key) => {
if (row.data && row.data[key]) {
if (typeof row.data[key] === "object") {
row.data[key] = { ...row.data[key], ...newData[key] };
row.data[key] = deepMerge(row.data[key], newData[key]);
} else {
row.data[key] = newData[key];
}
@ -90,6 +115,7 @@ export function updateCells(data, newRows) {
return updatedRows;
}
export function insertCell(data, newRow, expense_item_id) {
const updatedRows = JSON.parse(JSON.stringify(data));
const [row_type, depth, sort_order, dataRow] = newRow;
@ -107,24 +133,30 @@ export function insertCell(data, newRow, expense_item_id) {
return updatedRows;
}
let adding = false;
function findAndUpdate(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.data.header.expense_item_id === expense_item_id) {
if (
(row.sort_order >= newRowObject.sort_order) &
(row.data.line_id != newRowObject.data.line_id)
) {
row.sort_order = row.sort_order + 1;
}
if (!adding & (row.data.header.expense_item_id === expense_item_id)) {
if (!("subRows" in row)) {
row.subRows = [];
}
row.subRows.push(newRowObject);
return true;
adding = true;
}
if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0) {
const found = findAndUpdate(row.subRows);
if (found) return true;
}
}
return false;
}
findAndUpdate(updatedRows);
return updatedRows;
@ -133,21 +165,26 @@ export function insertCell(data, newRow, expense_item_id) {
export function deleteRow(data, lineId) {
const updatedRows = JSON.parse(JSON.stringify(data));
let rowDeleteSortOrder = false;
function findAndDelete(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowId = getDataRowId(row.data);
if (row.data.line_id == lineId) {
if (rowId == lineId) {
rowDeleteSortOrder = row.sort_order;
rows.splice(i, 1);
return true;
i--;
}
if ((rowDeleteSortOrder > 0) & (row.sort_order >= rowDeleteSortOrder)) {
row.sort_order = row.sort_order - 1;
}
if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0) {
const found = findAndDelete(row.subRows);
if (found) return true;
findAndDelete(row.subRows);
}
}
return false;
}
findAndDelete(updatedRows);
return updatedRows;

View File

@ -1,5 +1,9 @@
export function getDataRowId(data) {
return data?.id || data?.line_id;
}
export function getRowId(row) {
return row.original.data.id || row.original.data.line_id;
return getDataRowId(row.original.data);
}
export function isVspRow(row) {

View File

@ -25,7 +25,7 @@ import { StageStatusChip } from './StageStatusChip';
import { formatDateForApi } from './utils';
import CollapsibleTree from '../common/CollapsibleTree';
import { collectLeafColumns } from '../RealtimeTable/utils/columnUtils';
import { STAGE_ROLE_RUSSIAN_NAME } from '../../constants';
import { STAGE_ROLE_RUSSIAN_NAME } from '../../constants/constants';
export const AddStagesModal = ({
isOpen,

View File

@ -1,6 +1,6 @@
import { Box, Divider, Stack } from '@mui/material';
import { UnLockedSvg } from '../common/icons/icons';
import { ROLES_ID_RUSSIAN_NAME, EDIT_ACCESS_RUSSIAN } from '../../constants';
import { ROLES_ID_RUSSIAN_NAME, EDIT_ACCESS_RUSSIAN } from '../../constants/constants';
export const StageInfoForTooltip = ({ stageData = [] }) => {
const accessData = stageData[0] || [];

Some files were not shown because too many files have changed in this diff Show More