Compare commits

...

335 Commits

Author SHA1 Message Date
9bf17c6eb3 delete-projects: удаление проектов, фикс редактируемых полей в форме 4, нельзя удалить самого себя, нельзя выбрать неактивный ссп для экспорта всп, фикс для показа доступных полей для редактирования (бэк) 2026-07-30 14:30:42 +03:00
5498bf4935 Merge pull request 'fix-form-4' (#56) from fix-form-4 into test
Reviewed-on: #56
2026-07-29 14:57:44 +03:00
PotapovaA
f3473677d6 перевод sheet structure 2026-07-29 14:57:04 +03:00
PotapovaA
0acf66b2b5 небольшие правки 2026-07-29 14:22:18 +03:00
b23f2d0d1c Merge pull request '[AURORA-1076] - скрытие колонок при создании и редактировании этапа' (#55) from AURORA-1076 into test
Reviewed-on: #55
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-07-29 12:38:15 +03:00
17b848bd39 Merge pull request 'form-4-add-row' (#54) from form-4-add-row into test
Reviewed-on: #54
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-07-29 10:56:41 +03:00
c9410edb80 Merge branch 'test' into AURORA-1076 2026-07-28 18:33:45 +03:00
PotapovaA
64302ea127 Merge branch 'test' into form-4-add-row 2026-07-28 18:26:59 +03:00
ca7472213a [AURORA-1076] - скрытие колонок при создании и редактировании этапа 2026-07-28 18:24:28 +03:00
d9f67b68a9 Merge pull request '[AURORA-1206] - добавление новых типов в фильтрах журнал аудита' (#52) from AURORA-1206 into test
Reviewed-on: #52
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-07-28 17:23:32 +03:00
95f9e1a0d9 Merge pull request '[AURORA-894] - фикс удаления строк' (#51) from AURORA-894 into test
Reviewed-on: #51
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-07-28 17:23:18 +03:00
PotapovaA
8c978b4e33 добавлене программ, проектов в 4 форме 2026-07-28 16:03:52 +03:00
b4139cd66a Merge pull request 'fix-logs: пофиксил неверную запись entity id, убрал экспорт удаленных всп, нельзя выбрать удаленный ссп при создании проекта, добавил FK для таблиц…' (#53) from fix-logs into test
Reviewed-on: #53
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-07-28 13:17:37 +03:00
13e7791866 fix-logs: пофиксил неверную запись entity id, убрал экспорт удаленных всп, нельзя выбрать удаленный ссп при создании проекта, добавил FK для таблиц, поправил работу фильтра для журнала аудита по дате, админ может получить удаленный всп 2026-07-28 12:17:24 +03:00
a586ce3adc [AURORA-1206] - добавление новых типов в фильтрах журнал аудита 2026-07-28 09:59:40 +03:00
0b451b2499 [AURORA-894] - фикс удаления строк 2026-07-28 09:50:35 +03:00
PotapovaA
d257c3f1c1 add-row 2026-07-27 17:12:37 +03:00
e0941e0a5c Merge pull request 'add-line-log-hotfix: пишем лог корректно' (#50) from add-line-log-hotfix into test
Reviewed-on: #50
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-27 15:56:42 +03:00
PotapovaA
653ede59c1 Merge branch 'test' into form-4-add-row 2026-07-27 15:54:44 +03:00
49e2183693 add-line-log-hotfix: пишем лог корректно 2026-07-27 15:06:53 +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
PotapovaA
562707f418 исправила копирование данных 2026-07-22 14:48:34 +03:00
ed0e7c805b form-4-expense-item-filters: фильтры для expense-item 2026-07-21 17:38:16 +03:00
PotapovaA
73083153da добавление строк (не готово) 2026-07-21 17:24:13 +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
PotapovaA
8c88acab49 модалка для выбора всп 2026-06-26 17:48:22 +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
e2a16989ae Merge pull request 'front-logs-fix' (#8) from front-logs-fix into test
Reviewed-on: #8
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-06-26 12:04:54 +03:00
ecb2708b4e Merge pull request '[AURORA-1132] - ошибка закрепления колонок' (#7) from AURORA-1132 into test
Reviewed-on: #7
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-06-26 11:23:07 +03:00
b3b5574d86 Merge pull request '[AURORA-1147] - фикс расстояния между строчками при изменении масштаба' (#5) from AURORA-1147 into test
Reviewed-on: #5
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-06-26 11:22:22 +03:00
4cc2c120e5 [AURORA-1132] - ошибка закрепления колонок 2026-06-26 10:24:24 +03:00
bac2a7a780 logs: логируем время 2026-06-26 10:00:27 +03:00
e71864c37f Merge pull request '[AURORA-1131] - исправлен темный цвет текста на темном фоне в нередактируемой ячейке' (#6) from AURORA-1131 into test
Reviewed-on: #6
2026-06-26 09:47:47 +03:00
19dbc49dc0 Merge pull request 'sql-funcs-fix: поправил функции, агрегирующие данные не в том дирекшне' (#1) from sql-funcs-fix into test
Reviewed-on: #1
2026-06-26 09:45:30 +03:00
873f0d84b3 Merge pull request 'export-optimization: не красим обычные ячейки' (#4) from export-optimization into test
Reviewed-on: #4
2026-06-26 09:45:19 +03:00
f1bb6dbf6f [AURORA-1147] - фикс расстояния между строчками при имзенеии масшаба 2026-06-26 09:32:32 +03:00
PotapovaA
761a435b98 AURORA-1134 2026-06-25 18:54:32 +03:00
PotapovaA
3f9595928e этапы 2026-06-25 11:20:29 +03:00
59f9170e9f export-optimization: не красим обычные ячейки 2026-06-25 10:29:02 +03:00
da07e527e9 [AURORA-1132] - исправлен темный цвет текста на темном фоне в нередактируемой ячейке 2026-06-24 15:52:13 +03:00
230090ae22 Merge pull request 'vsp-dropdown-form: добавил фильтрацию по form_id в dropdown всп' (#73) from vsp-dropdown-form into test
Reviewed-on: #73
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-24 14:56:32 +03:00
840d5f5f13 sql-funcs-fix: поправил функции, агрегирующие данные не в том дирекшне 2026-06-24 14:41:56 +03:00
ab21b0e496 vsp-dropdown-form: добавил фильтрацию по form_id в dropdown всп 2026-06-24 13:22:17 +03:00
PotapovaA
b27ee53a9e Merge pull request 'скрыть отчет 9' (#72) from fix-front into test
Reviewed-on: #72
2026-06-24 12:27:47 +03:00
PotapovaA
cc766238f2 скрыть отчет 9 2026-06-24 12:27:29 +03:00
63ae001a23 Merge pull request 'migrations: закомментил все что связано с раисовской авторизацией при debug=false' (#70) from migrations into test
Reviewed-on: #70
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-24 11:42:22 +03:00
PotapovaA
04ee7b7fb2 Merge pull request 'fix этапов' (#71) from fix-front into test
Reviewed-on: #71
2026-06-24 11:08:58 +03:00
PotapovaA
c6502e59a7 fix этапов 2026-06-24 11:08:40 +03:00
fbf02d80d9 Merge pull request 'migrations: alembic миграции, пофиксил ошибки получения списков при пустых таблицах, добавил флаг --init_db для возможности заведения отдельной базы …' (#69) from migrations into test
Reviewed-on: #69
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-24 10:50:51 +03:00
c5c7963b5a migrations: закомментил все что связано с раисовской авторизацией при debug=false 2026-06-24 10:50:15 +03:00
2581589756 migrations: alembic миграции, пофиксил ошибки получения списков при пустых таблицах, добавил флаг --init_db для возможности заведения отдельной базы для тестов 2026-06-23 17:52:20 +03:00
PotapovaA
80c5b35299 Merge pull request 'fix-front' (#68) from fix-front into test
Reviewed-on: #68
2026-06-23 12:42:42 +03:00
PotapovaA
3aa9c96f25 fix 2026-06-23 12:42:22 +03:00
PotapovaA
6d0b48bc8d fix 2026-06-23 12:39:56 +03:00
PotapovaA
a33ba86ee5 fix цветов 2026-06-23 11:42:01 +03:00
PotapovaA
e5d2857e49 Merge pull request 'fix-front' (#67) from fix-front into test
Reviewed-on: #67
2026-06-23 11:32:55 +03:00
PotapovaA
c4d1a10fb8 удаление console.log 2026-06-23 11:32:33 +03:00
PotapovaA
1797044a2b fix accessorKey 2026-06-23 11:32:01 +03:00
PotapovaA
02aa4e8899 Merge branch 'test' into fix-front 2026-06-23 11:26:26 +03:00
PotapovaA
c7a8eb2e3e fix форм 2026-06-23 11:25:59 +03:00
eaad37ded7 Merge pull request 'websocket-self-flag: флаг присваивается по websocket, а не user_id' (#66) from websocket-self-flag into test
Reviewed-on: #66
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-23 11:16:44 +03:00
aceb1d38c1 websocket-self-flag: флаг присваивается по websocket, а не user_id 2026-06-23 09:33:15 +03:00
PotapovaA
41ccbe43d7 Merge pull request 'fix-front' (#65) from fix-front into test
Reviewed-on: #65
2026-06-23 04:00:11 +03:00
PotapovaA
6dc31a43b3 готово 2026-06-23 03:59:43 +03:00
PotapovaA
57eba099c1 скролл с виртуализацией fix 2026-06-23 02:47:53 +03:00
PotapovaA
2fcaea1d8b Merge pull request 'fix-front' (#64) from fix-front into test
Reviewed-on: #64
2026-06-22 20:34:14 +03:00
PotapovaA
0686b3ae8a нащвания форм + измененые формы 2026-06-22 20:33:55 +03:00
PotapovaA
c8165e8ca9 блокировка ячейки веб сокеты 2026-06-22 18:35:16 +03:00
PotapovaA
596ec13943 Merge branch 'test' into fix-front 2026-06-22 17:30:23 +03:00
d9e1cc4263 Merge pull request 'websocket-self-flag: добавил флаг is_self в сообщениях вебсокетов' (#63) from websocket-self-flag into test
Reviewed-on: #63
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-22 17:19:04 +03:00
PotapovaA
d138fdcfdb Merge branch 'test' into fix-front 2026-06-22 17:16:52 +03:00
PotapovaA
aa18a8e19c web сокеты для начала/конца редактирования ячейки 2026-06-22 17:16:29 +03:00
a2841d8c83 websocket-self-flag: добавил флаг is_self в сообщениях вебсокетов 2026-06-22 16:48:51 +03:00
PotapovaA
361eeb24bb Merge pull request 'ширина столбцов по умолчанию' (#62) from fix-front into test
Reviewed-on: #62
2026-06-22 13:57:42 +03:00
PotapovaA
ca11d55228 ширина столбцов по умолчанию 2026-06-22 13:57:24 +03:00
PotapovaA
2df64fb020 Merge pull request 'финансовый формат для ячеек' (#61) from fix-front into test
Reviewed-on: #61
2026-06-22 13:47:13 +03:00
PotapovaA
bb74f511af финансовый формат для ячеек 2026-06-22 13:46:56 +03:00
PotapovaA
66c1f09df6 Merge pull request 'формула для исполнителей' (#60) from fix-front into test
Reviewed-on: #60
2026-06-22 13:33:49 +03:00
PotapovaA
bafae2ca58 формула для исполнителей 2026-06-22 13:33:32 +03:00
27d698383e Merge pull request 'reload-fix: добавил локальный сваггер и обработку перезагрузки страницы фронтом' (#58) from reload-fix into test
Reviewed-on: #58
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-22 10:44:49 +03:00
PotapovaA
d2b1b3a4d0 Merge pull request 'фикс плывущей таблицы' (#59) from fix-front into test
Reviewed-on: #59
2026-06-22 10:20:15 +03:00
PotapovaA
d1d1f90268 фикс плывущей таблицы 2026-06-22 10:19:12 +03:00
b64120241c Merge pull request 'test-stand-fix: секреты для теста больше не нужно костылить, нужный урл для вебсокетов в контуре' (#57) from test-stand-fix into test
Reviewed-on: #57
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 17:06:31 +03:00
115c652887 reload-fix: добавил локальный сваггер и обработку перезагрузки страницы фронтом 2026-06-19 17:04:16 +03:00
75fa232bc6 test-stand-fix: секреты для теста больше не нужно костылить, нужный урл для вебсокетов в контуре 2026-06-19 14:44:47 +03:00
5cc07c7704 Merge pull request 'excel-empty-fix: убрал пустые колонки в экспорте эксель' (#56) from excel-empty-fix into test
Reviewed-on: #56
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 13:59:23 +03:00
6aa703e0f7 excel-empty-fix: убрал пустые колонки в экспорте эксель 2026-06-19 11:56:35 +03:00
3af55f09e2 Merge pull request 'requirements-fix: убрал зависимости из основного requirements для раисы' (#55) from requirements-fix into test
Reviewed-on: #55
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 11:38:01 +03:00
68ee519c39 requirements-fix: убрал зависимости из основного requirements для раисы 2026-06-19 10:28:49 +03:00
d99908c9b5 Merge pull request 'AURORA-1096-back: поправил фильтры в журнале аудита' (#54) from AURORA-1096-back into test
Reviewed-on: #54
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 08:23:05 +03:00
b9d109bd23 AURORA-1096-back: поправил фильтры в журнале аудита 2026-06-18 16:42:41 +03:00
PotapovaA
1e50ce6acb Merge pull request 'new stage' (#53) from new-stage-front into test
Reviewed-on: #53
2026-06-18 15:57:09 +03:00
PotapovaA
10909a1122 new stage 2026-06-18 15:55:15 +03:00
PotapovaA
70bf9fa5b5 Merge pull request 'front-logs' (#51) from front-logs into test
Reviewed-on: #51
2026-06-17 13:56:09 +03:00
5b6cda18b2 Merge pull request 'ws-user-id-fix: пишем для сессии бд пользователя' (#50) from ws-user-id-fix into test
Reviewed-on: #50
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-17 12:05:03 +03:00
c2c48789a1 Merge pull request 'expense-item-handler: получение expense-item по id' (#49) from expense-item-handler into test
Reviewed-on: #49
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-17 11:56:55 +03:00
7f7c9ab3eb ws-user-id-fix: пишем для сессии бд пользователя 2026-06-17 11:55:42 +03:00
PotapovaA
8a02913f2e Merge branch 'test' into front-logs 2026-06-17 11:22:56 +03:00
PotapovaA
9eced76d4b часть логов готова 2026-06-16 19:11:16 +03:00
8e5c92a132 expense-item-handler: получение expense-item по id 2026-06-16 17:12:13 +03:00
d292b2fbb0 Merge pull request 'excel-fix: пофиксил экспорт первой формы' (#48) from excel-fix into test
Reviewed-on: #48
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-16 15:49:05 +03:00
56cc1d6f87 Merge pull request 'Поправил некорректное отбраженеи ошибок' (#47) from fix_errors into test
Reviewed-on: #47
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-06-16 15:23:30 +03:00
Raykov-MS
5210f1d6f7 еще правки 2026-06-16 15:22:39 +03:00
c247536278 excel-fix: пофиксил экспорт первой формы 2026-06-16 14:56:55 +03:00
Raykov-MS
2f5cb21dc0 Поправил некорректное отбраженеи ошибок 2026-06-16 12:52:39 +03:00
4f7b4aaae5 Merge pull request 'vsp-edit-field-fix: поправил название изменяемого исполнителем поля для проверки' (#46) from vsp-edit-field-fix into test
Reviewed-on: #46
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-10 12:08:16 +03:00
f2016e3eb6 vsp-edit-field-fix: поправил название изменяемого исполнителем поля для проверки 2026-06-10 10:10:08 +03:00
f079423c0f Merge pull request 'fix-error-message: вывод ошибки с бэка при работе со справочниками' (#45) from fix-error-message into test
Reviewed-on: #45
2026-06-09 15:29:15 +03:00
0d16a002b9 fix-error-message: вывод ошибки с бэка при работе со справочниками 2026-06-09 15:28:00 +03:00
27cefec47b Merge pull request '[AURORA-1041] - работа со справочниками' (#44) from AURORA-1041-front into test
Reviewed-on: #44
2026-06-09 14:35:15 +03:00
d3d1babe76 [AURORA-1041] - работа со справочниками 2026-06-09 14:32:33 +03:00
8ef3b1cca1 Merge pull request 'vsp-fix-back: фикс багов по всп' (#43) from vsp-fix-back into test
Reviewed-on: #43
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-09 12:49:51 +03:00
b328afb52d vsp-fix-back: фикс багов по всп 2026-06-09 12:06:54 +03:00
b5a6114389 Merge pull request 'vsp-back: перенес функциональность справочников всп' (#40) from vsp-back into test
Reviewed-on: #40
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-08 17:23:10 +03:00
95d48bb2a0 vsp-back: перенес функциональность справочников всп 2026-06-08 17:15:38 +03:00
96477df9e3 Merge pull request '[AURORA-1043] - создание задач' (#42) from AURORA-1043 into test
Reviewed-on: #42
2026-06-08 16:48:55 +03:00
75f810da07 [AURORA-1043] - передача ССП списком 2026-06-08 16:47:09 +03:00
ef335cf107 Merge branch 'test' into AURORA-1043 2026-06-08 16:42:07 +03:00
d43b6faa2a Merge pull request 'create-form-back: в ручку создания задач передается список ссп, поправил падающий тест' (#41) from create-form-back into test
Reviewed-on: #41
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-08 16:38:15 +03:00
098efcb497 create-form-back: в ручку создания задач передается список ссп, поправил падающий тест 2026-06-08 16:09:11 +03:00
c3a4c9436a [AURORA-1043] - создание задачи 2026-06-08 15:48:52 +03:00
4308e11bc9 Merge pull request 'create-form: создание задачи' (#39) from create-form-back into test
Reviewed-on: #39
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-08 12:57:04 +03:00
2073569765 create-form: создание задачи 2026-06-08 12:42:36 +03:00
e7e8bc0df2 Merge pull request '[AURORA-1058] - Отсутствует кнопка для возврата к предыдущему экрану/странице' (#38) from AURORA-1058 into test
Reviewed-on: #38
2026-06-08 10:54:21 +03:00
32a7c001eb [AURORA-1058] - Отсутствует кнопка для возврата к предыдущему экрану/странице 2026-06-08 10:52:56 +03:00
a206bb7191 Merge pull request 'fix-stages: поправлены ручки по этапам и отображение данных' (#37) from fix-stages into test
Reviewed-on: #37
2026-06-05 17:00:24 +03:00
3970fe5c35 fix-stages: поправлены ручки по этапам и отображение данных 2026-06-05 16:57:41 +03:00
ee6fafb84f Merge pull request '[AURORA-1057] - экспорт форм' (#36) from AURORA-1057 into test
Reviewed-on: #36
2026-06-05 13:51:52 +03:00
e2bd55e5b1 [AURORA-1057] - добавлен экспорт таблиц 2026-06-05 13:04:47 +03:00
b27b8f3143 Merge branch 'test' into AURORA-1057 2026-06-05 12:03:41 +03:00
61a80a9e7d Merge pull request '[AURORA-1070] - Ошибка редактирования количества пользователей в Подразделении СПП/РФ' (#35) from AURORA-1070 into test
Reviewed-on: #35
2026-06-05 12:02:17 +03:00
d6a8f5ae58 Merge pull request '[AURORA-1060] - Вирутальный рендеринг' (#34) from AURORA-1060 into test
Reviewed-on: #34
2026-06-05 12:01:35 +03:00
e20461a0ff [AURORA-1060] - попраивла отображение лоадера 2026-06-05 11:17:10 +03:00
a47d2e58d0 [AURORA-1060] - добавлен лоадер для таблицы 2026-06-05 10:34:34 +03:00
67ce43e524 Merge branch 'test' into AURORA-1057 2026-06-04 17:39:35 +03:00
3bf44c50d4 Merge pull request 'export-direction-fix: убрал обязательный для первой формы direction при экспорте формы' (#33) from export-direction-fix into test
Reviewed-on: #33
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 17:35:46 +03:00
71deb55485 export-direction-fix: убрал обязательный для первой формы direction при экспорте формы 2026-06-04 17:29:59 +03:00
9fc054acd4 [AURORA-1057] - Ошибка экспорта задач 2026-06-04 17:16:05 +03:00
ed31f6b519 Merge pull request 'change-role-fix: обновленное значение ссп при смене роли' (#32) from change-role-fix into test
Reviewed-on: #32
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 16:05:17 +03:00
7d34376abe change-role-fix: обновленное значение ссп при смене роли 2026-06-04 16:01:13 +03:00
6419b5ad1d [AURORA-1070] - Ошибка редактирования количества пользователей в Подразделении СПП/РФ 2026-06-04 15:11:15 +03:00
e47200733e Merge pull request 'org-unit-users-fix: список пользователей с id' (#31) from org-unit-users-fix into test
Reviewed-on: #31
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 14:39:16 +03:00
758b4d1315 org-unit-users-fix: список пользователей с id 2026-06-04 14:30:53 +03:00
14d2271eb9 Merge pull request '[AURORA-1061] - Подключить админку к новому бэку' (#30) from AURORA-1061 into test
Reviewed-on: #30
2026-06-04 14:11:15 +03:00
8966829ad9 [AURORA-1061] - Подключить админку к новому бэку 2026-06-04 12:04:22 +03:00
b6a7ae509e Merge pull request 'audit-fix: загрузка пользователя в журнале аудита' (#29) from audit-fix into test
Reviewed-on: #29
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 10:28:07 +03:00
8e07135a16 audit-fix: загрузка пользователя в журнале аудита 2026-06-04 10:25:13 +03:00
bf3896f2f0 Merge pull request 'org-archive: в ручке обновления org доступно in_active, фильтры в get на is_active и is_ssp' (#28) from org-archive into test
Reviewed-on: #28
2026-06-03 17:40:23 +03:00
1db445d63d org-archive: в ручке обновления org доступно in_active, фильтры в get на is_active и is_ssp 2026-06-03 17:39:10 +03:00
1a533f46f1 Merge pull request 'org-unit-user-create: список ССП и РФ при создании и обновлении юзеров' (#27) from org-unit-user-create into test
Reviewed-on: #27
2026-06-03 16:10:55 +03:00
f3eb75cf51 org-unit-user-create: список ССП и РФ при создании и обновлении юзеров 2026-06-03 16:07:35 +03:00
43703866aa [AURORA-1060] - Вирутальный рендеринг 2026-06-03 11:49:06 +03:00
e0dc613c3c Merge pull request 'del-row-fix: поправил дисконнект при удалении строки' (#26) from del_row_fix into test
Reviewed-on: #26
2026-06-02 10:09:38 +03:00
35ab5e7b5f del-row-fix: поправил дисконнект при удалении строки 2026-06-02 10:02:52 +03:00
7f837c34a7 Merge pull request 'поменял получение cell_key для блокировки ячеек' (#25) from cell_key_fix into test
Reviewed-on: #25
2026-06-01 13:13:53 +03:00
d95465abe4 поменял получение cell_key для блокировки ячеек 2026-06-01 12:42:28 +03:00
PotapovaA
75f86e6ef7 Merge pull request 'del-row' (#24) from del-row into test
Reviewed-on: #24
2026-05-29 18:39:31 +03:00
PotapovaA
56833f454b исправила иерархию, блокировка ячеек, виртуализация строк 2026-05-29 18:38:05 +03:00
30e3426408 Merge pull request 'блокировки ячеек для websockets' (#23) from websocket_locks into test
Reviewed-on: #23
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-29 14:02:32 +03:00
38b0033c4a блокировки ячеек для websockets 2026-05-29 13:52:20 +03:00
PotapovaA
fcece07c64 удаление строки 2026-05-29 13:51:47 +03:00
PotapovaA
f6be77d889 Merge pull request 'front' (#22) from front into test
Reviewed-on: #22
2026-05-29 11:26:03 +03:00
PotapovaA
f93b4811a5 Merge branch 'test' into front 2026-05-29 11:23:45 +03:00
PotapovaA
99e7a445cf добавление строки 2026-05-29 11:23:25 +03:00
023f2974df Merge pull request 'id новой строки в ответе при вставке в вебсокетах' (#20) from new_line_id into test
Reviewed-on: #20
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-29 11:15:49 +03:00
5f5ed32cd8 id новой строки в ответе при вставке в вебсокетах 2026-05-29 10:06:39 +03:00
PotapovaA
52de655149 ws для ячеек 2026-05-28 12:12:16 +03:00
PotapovaA
079e719f71 ws - в процессе 2026-05-27 16:21:04 +03:00
PotapovaA
8b16901f1b Merge branch 'test' into front 2026-05-26 19:06:27 +03:00
PotapovaA
93881c7194 ws 2026-05-26 19:06:01 +03:00
3f6d59ba06 Merge pull request 'admin-back: ручки для админки' (#19) from admin-back into test
Reviewed-on: #19
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-26 17:32:31 +03:00
b7557f7b3d admin-back: ручки для админки 2026-05-26 17:07:46 +03:00
PotapovaA
bc05bc8154 Merge branch 'test' into front 2026-05-26 15:24:49 +03:00
PotapovaA
58d67d219f форма 2026-05-26 15:24:28 +03:00
aca03d59b7 Merge pull request 'Export' (#18) from export into test
Reviewed-on: #18
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-25 18:46:39 +03:00
Raykov-MS
fc55a7b042 Правки 2026-05-25 18:46:11 +03:00
47bb32da56 Merge pull request 'Исправил null в логах' (#17) from future/AURORA-1012 into test
Reviewed-on: #17
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-25 17:53:37 +03:00
Raykov-MS
ad6eae9c0b Export 2026-05-25 17:31:29 +03:00
Raykov-MS
d14d39cd27 Исправил null в логах 2026-05-22 18:02:06 +03:00
PotapovaA
a92df7ae14 форма 1 2026-05-22 17:30:28 +03:00
e37eb09e0a Merge pull request 'Логи с уходом от орм' (#16) from future/AURORA-1012 into test
Reviewed-on: #16
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-22 09:57:27 +03:00
Raykov-MS
d8e260cc5b add tests 2026-05-21 17:24:45 +03:00
Raykov-MS
03cd022dac None to bool 2026-05-21 16:19:54 +03:00
Raykov-MS
ed4c02589b Merge branch 'future/AURORA-1012' of ssh://git.aepif.ru:2222/RSHB/DFiP_Budget_planing into future/AURORA-1012 2026-05-21 14:32:47 +03:00
PotapovaA
76ce3702f5 Merge branch 'test' into front 2026-05-21 12:40:19 +03:00
PotapovaA
499664511a стили 2026-05-21 12:39:42 +03:00
2191e7d998 Merge pull request 'form-fix: ручка для получения формы по id' (#15) from form-fix into test
Reviewed-on: #15
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-21 11:57:09 +03:00
73c61918f7 form-fix: ручка для получения формы по id 2026-05-21 11:44:28 +03:00
Raykov-MS
e129f562dc Ушел от try except и вынес rollback на уровень сессии 2026-05-21 11:20:44 +03:00
PotapovaA
b580e38117 Merge branch 'test' into front 2026-05-21 11:10:01 +03:00
PotapovaA
b8a1427cf6 стили 2026-05-21 11:09:31 +03:00
Raykov-MS
411773b6e9 Ушли от уровня орм к уровню бд, позже добавлю функции в rnd репо 2026-05-21 11:08:36 +03:00
d2559d6bb7 Merge pull request 'ws: вебсокеты на новой модели данных' (#13) from ws into test
Reviewed-on: #13
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-21 11:03:58 +03:00
c247d13829 Merge pull request 'ssp-fix: org_unit в списке форм' (#14) from ssp-fix into test
Reviewed-on: #14
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-21 11:03:34 +03:00
33055d6695 ws: вебсокеты на новой модели данных 2026-05-21 11:02:44 +03:00
772afae276 ssp-fix: org_unit в списке форм 2026-05-21 10:39:33 +03:00
06ad5e0e93 Merge pull request 'Логи' (#12) from future/AURORA-1012 into test
Reviewed-on: #12
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-21 10:25:21 +03:00
Raykov-MS
831f220dcb Add annotation 2026-05-20 17:33:38 +03:00
Raykov-MS
8e43920deb фикс правок ревью 2026-05-20 17:30:40 +03:00
Raykov-MS
4a7ac50ca7 fix tests 2026-05-20 11:18:05 +03:00
Raykov-MS
816f0e06bb solve merge conflicts 2026-05-20 10:44:45 +03:00
Raykov-MS
bea8eb6240 Add audit read api and remove python audit writes 2026-05-19 14:37:43 +03:00
PotapovaA
188189534b Merge branch 'test' into front 2026-05-19 10:35:28 +03:00
PotapovaA
ff2ae30623 form 2026-05-19 10:35:06 +03:00
2dff2a1f21 Merge pull request 'add integrated tests' (#11) from future/AURORA-1011 into test
Reviewed-on: #11
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-19 10:25:36 +03:00
PotapovaA
24a59fb853 front new 2026-05-18 17:33:45 +03:00
Raykov-MS
5cd7232eb2 add integrated tests 2026-05-18 16:12:16 +03:00
7e9fc08056 Merge pull request 'add and fix validations on forms' (#10) from future/AURORA-1009 into test
Reviewed-on: #10
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-18 14:27:43 +03:00
Raykov-MS
a718827c12 add and fix validations on forms 2026-05-15 18:42:05 +03:00
21d474464d Merge pull request 'stages-back: круды для этапов' (#9) from stages-back into test
Reviewed-on: #9
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-15 15:18:41 +03:00
8299b35d07 stages-back: круды для этапов 2026-05-15 15:17:05 +03:00
47fac8acbc Merge pull request 'future/AURORA-1008' (#8) from future/AURORA-1008 into test
Reviewed-on: #8
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-15 10:23:52 +03:00
Raykov-MS
f1f8f5c7b6 resolve src.db.models 2026-05-14 17:40:51 +03:00
Raykov-MS
3d83a55ad8 conflicts 2026-05-14 17:37:27 +03:00
Raykov-MS
2a3827ff91 Add services endpoints 2026-05-14 17:36:35 +03:00
05d1f2afb0 Merge pull request 'refactor tests for src.db.models... and for new code, message, field error response' (#7) from future/AURORA-1007 into test
Reviewed-on: #7
2026-05-14 17:05:56 +03:00
Raykov-MS
13db21c4af refactor tests for src.db.models... and for new code, message, field error response 2026-05-14 16:22:59 +03:00
748d81ef06 Merge pull request 'future/AURORA-1007' (#4) from future/AURORA-1007 into test
Reviewed-on: #4
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-14 16:13:28 +03:00
Raykov-MS
312c8db04f conflicts 2026-05-14 16:10:32 +03:00
Raykov-MS
0f46157cb5 Merge branch 'future/AURORA-1007' of ssh://git.aepif.ru:2222/RSHB/DFiP_Budget_planing into future/AURORA-1007 2026-05-14 16:05:06 +03:00
Raykov-MS
de000905b3 Add Constants 2026-05-14 16:04:39 +03:00
bdea6f9f4b Merge pull request 'form_124' (#6) from form_124 into test
Reviewed-on: #6
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-14 15:56:09 +03:00
a3540e66c0 Merge pull request 'form_124_write: запись в формы 1 2 4 и тесты' (#5) from form_124_write into form_124
Reviewed-on: #5
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-14 15:31:55 +03:00
0e639ac433 form_124_write: запись в формы 1 2 4 и тесты 2026-05-14 15:04:25 +03:00
79ad4a444c Merge pull request 'F3 Read/Write endpoints' (#2) from form_3 into form_124
Reviewed-on: #2
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-14 11:26:54 +03:00
Raykov-MS
7b78a817cd delete models.py 2026-05-14 11:25:53 +03:00
Raykov-MS
539bd8c0f9 Refactor for specs requirements 2026-05-14 11:21:26 +03:00
Raykov-MS
280dabfd95 Refactor contract of errors 2026-05-14 11:16:35 +03:00
Raykov-MS
52c0d1806a F3 Read/Write endpoints 2026-05-13 12:28:00 +03:00
Raykov-MS
3d134fb7f9 srez 2026-05-08 13:16:31 +03:00
c67c94ba1f WIP: READ ручки для форм 1 2 4 2026-05-08 12:33:05 +03:00
402 changed files with 232092 additions and 518 deletions

141
api/alembic.ini Normal file
View File

@ -0,0 +1,141 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
api/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

66
api/alembic/env.py Normal file
View File

@ -0,0 +1,66 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from src.core.config import settings
from src.db.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_schemas=True,
version_table_schema="v3",
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
version_table_schema="v3",
)
with context.begin_transaction():
context.execute(f"CREATE SCHEMA IF NOT EXISTS v3")
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_async_engine(
settings.DATABASE_URL,
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
import os
import re
from typing import List, Optional
from alembic import op
revision = "0002"
down_revision = "0001"
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", "0002_functions.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,30 @@
import os
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
if has_data:
return
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
with open(sql_path) as f:
content = f.read()
for stmt in content.split(";\n"):
stripped = stmt.strip()
if not stripped:
continue
op.execute(stripped)
def downgrade() -> None:
pass

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

@ -0,0 +1,84 @@
import os
import re
import time
from alembic import op
import sqlalchemy as sa
revision = "0011"
down_revision = "0010"
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", "0011_form3_log.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,84 @@
import os
import re
import time
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
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", "0012_del_project.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

File diff suppressed because one or more lines are too long

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,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',
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

@ -0,0 +1,172 @@
CREATE OR REPLACE FUNCTION v3.add_project_year(p_project_id integer, p_year integer, p_emit_audit boolean DEFAULT true)
RETURNS TABLE(limit_report_id integer, current_expenses_report_id integer)
LANGUAGE plpgsql
AS $function$
DECLARE
v_lim INT;
v_cur INT;
BEGIN
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
IF EXISTS (SELECT 1 FROM v3.rf_project_report
WHERE project_id = p_project_id AND year = p_year) THEN
RAISE EXCEPTION 'год % у проекта id=% уже заведён', p_year, p_project_id;
END IF;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (p_project_id, p_year, 'LIMIT') RETURNING id INTO v_lim;
INSERT INTO v3.rf_project_report (project_id, year, report_type)
VALUES (p_project_id, 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.seed_form3_report_lines(v_lim);
PERFORM v3.seed_form3_report_lines(v_cur);
IF p_emit_audit THEN
PERFORM v3.log_event(
'PROJECT_YEAR_ADD', 'PROJECT',
jsonb_build_object(
'project_id', p_project_id,
'year', p_year,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
)
);
END IF;
RETURN QUERY SELECT v_lim, v_cur;
END;
$function$
;
-- FKs
ALTER TABLE v3.app_user ADD CONSTRAINT app_user_role_fk FOREIGN KEY (role_id) REFERENCES v3."role"(id);
ALTER TABLE v3.audit_log ADD CONSTRAINT audit_log_app_user_fk FOREIGN KEY (user_id) REFERENCES v3.app_user(id);
ALTER TABLE v3.audit_log ADD CONSTRAINT audit_log_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
ALTER TABLE v3.audit_log ADD CONSTRAINT audit_log_budget_form_fk FOREIGN KEY (form_id) REFERENCES v3.budget_form(id);
ALTER TABLE v3.booking ADD CONSTRAINT booking_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.budget_form ADD CONSTRAINT budget_form_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.budget_form ADD CONSTRAINT budget_form_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.budget_form ADD CONSTRAINT budget_form_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.budget_line_quarter ADD CONSTRAINT budget_line_quarter_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.ckk ADD CONSTRAINT ckk_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.collegial_approval ADD CONSTRAINT collegial_approval_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.contract_detail ADD CONSTRAINT contract_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.contract_summary ADD CONSTRAINT contract_summary_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.expense_item ADD CONSTRAINT expense_item_expense_item_fk FOREIGN KEY (parent_id) REFERENCES v3.expense_item(id);
ALTER TABLE v3.expense_item_form_type ADD CONSTRAINT expense_item_form_type_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
ALTER TABLE v3.fixed_asset_report ADD CONSTRAINT fixed_asset_report_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
ALTER TABLE v3.fixed_asset_report ADD CONSTRAINT fixed_asset_report_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
ALTER TABLE v3.form3_phase ADD CONSTRAINT form3_phase_rf_project_report_fk FOREIGN KEY (rf_project_report_id) REFERENCES v3.rf_project_report(id);
ALTER TABLE v3.form_limit ADD CONSTRAINT form_limit_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
ALTER TABLE v3.form_phase ADD CONSTRAINT form_phase_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
ALTER TABLE v3.phase_template ADD CONSTRAINT phase_template_form_type_fk FOREIGN KEY (form_type) REFERENCES v3.form_type(code);
ALTER TABLE v3."plan" ADD CONSTRAINT plan_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.project ADD CONSTRAINT project_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
ALTER TABLE v3.rent_detail ADD CONSTRAINT rent_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.rent_detail ADD CONSTRAINT rent_detail_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
ALTER TABLE v3.reserve ADD CONSTRAINT reserve_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.rf_project_report ADD CONSTRAINT rf_project_report_project_fk FOREIGN KEY (project_id) REFERENCES v3.project(id);
ALTER TABLE v3.rf_project_report ADD CONSTRAINT rf_project_report_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.rf_project_report ADD CONSTRAINT rf_project_report_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.rf_project_report_line ADD CONSTRAINT rf_project_report_line_rf_project_report_fk FOREIGN KEY (rf_project_report_id) REFERENCES v3.rf_project_report(id);
ALTER TABLE v3.rf_project_report_line ADD CONSTRAINT rf_project_report_line_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
ALTER TABLE v3.rf_project_report_quarter ADD CONSTRAINT rf_project_report_quarter_rf_project_report_line_fk FOREIGN KEY (rf_project_report_line_id) REFERENCES v3.rf_project_report_line(id);
ALTER TABLE v3.security_detail ADD CONSTRAINT security_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.security_detail ADD CONSTRAINT security_detail_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
ALTER TABLE v3.sequestration ADD CONSTRAINT sequestration_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.user_org ADD CONSTRAINT user_org_app_user_fk FOREIGN KEY (user_id) REFERENCES v3.app_user(id);
ALTER TABLE v3.user_org ADD CONSTRAINT user_org_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
ALTER TABLE v3.utility_detail ADD CONSTRAINT utility_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
ALTER TABLE v3.utility_detail ADD CONSTRAINT utility_detail_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
ALTER TABLE v3.vsp ADD CONSTRAINT vsp_org_unit_fk FOREIGN KEY (branch_id) REFERENCES v3.org_unit(id);
ALTER TABLE v3.vsp ADD CONSTRAINT vsp_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
ALTER TABLE v3.vsp ADD CONSTRAINT vsp_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
-- DROP FUNCTION v3.add_project(varchar, int4, int4, varchar, int4, varchar, varchar, varchar, varchar, int4, numeric, varchar);
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 ou WHERE id = p_org_unit_id AND ou.is_active ) 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$
;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

@ -10,8 +10,6 @@ python-multipart==0.0.9
python-dotenv==1.0.0
httpx==0.27.0
alembic==1.16.1
aiosqlite==0.20.0
asyncpg==0.30.0
pytest==8.3.2
pytest-asyncio==0.24.0
raisa-fastapi-protected-api==1.0.0
openpyxl

25
api/src/api/v1/admin.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from src.api.v1.deps import require_admin
from src.db.base import engine
from src.db.models.app_user import AppUser
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/refresh-tree")
async def refresh_tree(current_user: AppUser = Depends(require_admin)):
"""Обновляет MV дерева статей расходов."""
try:
async with engine.connect() as conn:
# CONCURRENTLY требует autocommit-режим.
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
await conn.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree"))
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Не удалось обновить mv_expense_item_tree: {exc}",
) from exc
return {"status": "ok", "message": "mv_expense_item_tree refreshed"}

46
api/src/api/v1/audit.py Normal file
View File

@ -0,0 +1,46 @@
from datetime import datetime
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import require_admin
from src.db.session import get_db
# from src.domain.models import Users
from src.db.models.app_user import AppUser
from src.domain.schemas import AuditLog, AuditLogQueryParams, BaseListResponse
from src.services.auditlog_service import AuditLogService
router = APIRouter(tags=["audit"])
@router.get(
"/audit-logs",
response_model=BaseListResponse[AuditLog],
status_code=status.HTTP_200_OK,
summary="Получение журнала аудита",
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
)
async def get_audit_logs(
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
params: AuditLogQueryParams = Depends(),
):
offset = (params.page - 1) * params.limit
audit_service = AuditLogService(db)
logs, count = await audit_service.get_all(
user=current_user,
limit=params.limit,
offset=offset,
user_id=params.user_id,
org_unit_id=params.org_unit_id,
task_id=params.task_id,
form_id=params.form_id,
event_type=params.event_type,
event=params.event,
date_from=datetime.combine(params.date_from, datetime.min.time()) if params.date_from else None,
date_to=datetime.combine(params.date_to, datetime.max.time()) if params.date_to else None,
)
result = [audit_service.orm_log_to_response(log) for log in logs]
return BaseListResponse(result=result, count=count)

View File

@ -6,46 +6,46 @@ from src.db.session import get_db
from src.domain.schemas import LoginRequest, RefreshRequest, Token
from src.services.auth_service import AuthService
if not settings.DEBUG:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# if not settings.DEBUG:
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
router = APIRouter(prefix="/auth", tags=["auth"])
if settings.DEBUG:
# if settings.DEBUG:
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user(login_data.username, login_data.password)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
return token
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user(login_data.username, login_data.password)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
return token
else:
# else:
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
user: UserInfo = Depends(get_user_dependency),
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user_via_email(user.email)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
return token
# @router.post("/login", response_model=Token)
# async def login(
# login_data: LoginRequest,
# db: AsyncSession = Depends(get_db),
# user: UserInfo = Depends(get_user_dependency),
# ):
# auth_service = AuthService(db)
# token = await auth_service.authenticate_user_via_email(user.email)
# if not token:
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Неверное имя пользователя или пароль",
# headers={"WWW-Authenticate": "Bearer"},
# )
# return token
@router.post("/login-form", response_model=Token)

View File

@ -5,71 +5,89 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings
from src.core.security import verify_token
from src.db.session import get_db
from src.domain.models import UserRole, Users
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
security = HTTPBearer()
if settings.DEBUG:
# if settings.DEBUG:
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Users:
token = credentials.credentials
payload = verify_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_user_by_token(
token: str,
db: AsyncSession,
) -> AppUser:
payload = verify_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
username: str | None = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
username: str | None = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
user_repo = UserRepository(db)
user = await user_repo.get_by_username(username)
if user is None:
user = await user_repo.get_by_email(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return user
user_repo = UserRepository(db)
user = await user_repo.get_by_username(username)
if user is None or not user.is_active:
user = await user_repo.get_by_email(username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return user
else:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AppUser:
return await get_user_by_token(token=credentials.credentials, db=db)
async def get_current_user(
user: UserInfo = Depends(get_user_dependency),
db: AsyncSession = Depends(get_db),
) -> Users:
user_repo = UserRepository(db)
db_user = await user_repo.get_by_email(user.email)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return db_user
# else:
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# async def get_current_user(
# user: UserInfo = Depends(get_user_dependency),
# db: AsyncSession = Depends(get_db),
# ) -> AppUser:
# user_repo = UserRepository(db)
# db_user = await user_repo.get_by_email(user.email)
# if db_user is None:
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Пользователь не найден",
# headers={"WWW-Authenticate": "Bearer"},
# )
# return db_user
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
return current_user
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id != UserRole.ADMIN:
async def get_current_active_user_with_set_db(
current_user: AppUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> AppUser:
user_repo = UserRepository(db)
await user_repo.set_app_user_id(current_user.id)
return current_user
def require_admin(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> AppUser:
if current_user.role_id != UserRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",
@ -77,11 +95,13 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use
return current_user
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
def require_executor(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> AppUser:
if current_user.role_id not in [
UserRole.ADMIN,
UserRole.EXECUTOR_DFIP,
UserRole.EXECUTOR_RF,
UserRoleEnum.ADMIN,
UserRoleEnum.EXECUTOR_DFIP,
UserRoleEnum.EXECUTOR_RF,
]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -90,8 +110,10 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) ->
return current_user
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
def require_executor_dfip(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> AppUser:
if current_user.role_id not in [UserRoleEnum.ADMIN, UserRoleEnum.EXECUTOR_DFIP]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",

View File

@ -0,0 +1,62 @@
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, require_executor
from src.db.session import get_db
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
ExpenseItemResponseSchema,
)
router = APIRouter(prefix="/expense-item", tags=["expense-item"])
@router.get("/{item_id}", response_model=BaseSingleResponse[ExpenseItemResponseSchema])
async def get_expense_item_by_id(
item_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
"""Получение записи expense item по ID."""
service = ExpenseItemService(db)
result = await service.get(item_id, user=current_user)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Expense Item не найден",
)
return BaseSingleResponse(
success=True,
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],
)

155
api/src/api/v1/export.py Normal file
View File

@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user_with_set_db
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import DirectionSchemaEnum, ExportBulkRequest
from src.services.export_service import ExportService
router = APIRouter(prefix="/export", tags=["export"])
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
def _parse_sections_csv(sections: str | None) -> list[str] | None:
if sections is None:
return None
raw = [section.strip() for section in sections.split(",") if section.strip()]
normalized: list[str] = []
seen: set[str] = set()
quarter_tokens = {"q1", "q2", "q3", "q4", "totals"}
for section in raw:
if "." in section:
base_section, nested_section = section.split(".", 1)
nested_section = nested_section.strip()
tokens = [base_section.strip()]
if nested_section in quarter_tokens:
tokens.append(nested_section)
for token in tokens:
if token and token not in seen:
seen.add(token)
normalized.append(token)
continue
if section not in seen:
seen.add(section)
normalized.append(section)
return normalized or None
def _parse_form3_sections_csv(sections: str | None) -> list[str] | None:
if not sections:
return None
parsed = [section.strip() for section in sections.split(",") if section.strip()]
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
if invalid:
raise ValueError(f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
return parsed
@router.get("/form/{form_id}")
async def export_form(
form_id: int,
direction: DirectionSchemaEnum | None = None,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_form_payload(
form_id=form_id,
current_user=current_user,
direction=direction.value if direction else None,
sections=_parse_sections_csv(sections),
ignore_non_applicable_query_params=True,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)
@router.get("/form/{form_id}/sheet/{sheet}")
async def export_form_sheet(
form_id: int,
sheet: str,
direction: DirectionSchemaEnum | None = None,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_form_payload(
form_id=form_id,
current_user=current_user,
sheet=sheet,
direction=direction.value if direction else None,
sections=_parse_sections_csv(sections),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)
@router.post("/bulk")
async def export_forms_bulk(
payload: ExportBulkRequest,
direction: DirectionSchemaEnum | None = None,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_bulk_payload(
form_ids=payload.form_ids,
current_user=current_user,
skip_failed=payload.skip_failed,
direction=direction.value if direction else None,
sections=_parse_sections_csv(sections),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)
@router.get("/project/{project_id}/report/{year}/{report_type}")
async def export_project_report(
project_id: int,
year: int,
report_type: str,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_project_report_payload(
project_id=project_id,
year=year,
report_type=report_type,
current_user=current_user,
sections=_parse_form3_sections_csv(sections),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)

View File

@ -0,0 +1,344 @@
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,
)
from src.services.budget_form_service import BudgetFormService
from src.services.form_phase_service import FormPhaseService
from src.api.v1.deps import (
get_current_active_user_with_set_db,
require_admin,
)
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,
sheet: 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[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=result,
count=len(phases),
)
@router.post("/form/{form_id}")
async def create_form_phase(
form_id: int,
body: FormPhaseCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
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,
)
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}")
async def update_form_phase(
form_id: int,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.update(
budget_form=form,
sheet=sheet,
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=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}")
async def delete_form_phase(
form_id: int,
sheet: str,
phase_code: str,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
deleted = await fp_service.delete(
budget_form_id=form_id,
sheet=sheet,
phase_code=phase_code,
user=current_user,
)
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, "Этап не найден")

468
api/src/api/v1/forms.py Normal file
View File

@ -0,0 +1,468 @@
import logging
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.org_unit_service import OrgUnitService
from src.services.budget_line_service import BudgetLineService
from src.db.models.form_type import FormTypeEnum
from src.services.sheet_service import SheetService
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.db.models.app_user import AppUser
from src.services.budget_form_service import BudgetFormService
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
from src.db.session import get_db
router = APIRouter(prefix="/form", tags=["forms"])
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(
row_type=el[0],
depth=el[1],
sort_order=el[2],
data=el[3],
) for el in result
]
def _parse_sections_csv(sections: Optional[str]) -> Optional[list[str]]:
if sections is None:
return None
parsed = [section.strip() for section in sections.split(",") if section.strip()]
return parsed or None
def _validate_sheet_query_params(
form_type_code: FormTypeEnum,
sheet: str,
direction: Optional[DirectionSchemaEnum],
sections: Optional[list[str]],
) -> None:
if form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS and not direction:
raise HTTPException(400, "direction обязателен для FORM_1 листов AHR/CAP")
if direction and not (
form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
):
raise HTTPException(400, "direction допустим только для FORM_1 листов AHR/CAP")
if sections and sheet not in SHEETS_WITH_SECTIONS:
raise HTTPException(400, f"sections не поддерживается для листа {sheet}")
@router.get("/")
async def get_forms(
offset: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[BudgetFormResponse]:
"""Все строки v3.budget_form для UI-выпадашки."""
bf_service = BudgetFormService(db)
count, forms = await bf_service.get_list(
user=current_user,
with_count=True,
offset=offset,
limit=limit,
load_org=True,
)
return BaseListResponse(
result = [
BudgetFormResponse.model_validate(form) for form in forms
],
count=count,
)
@router.get("/{form_id}")
async def get_form(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_org=True,
)
return BaseSingleResponse(
result = BudgetFormResponse.model_validate(form),
)
@router.get("/{form_id}/sheets")
async def get_form_sheets(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[SheetFormTypeResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
return BaseSingleResponse(
result=SheetFormTypeResponse(
form_type=form.form_type.code,
sheets=form.form_type.sheet_list,
all_sheets=form.form_type.sheet_list_with_directions,
)
)
@router.get("/{form_id}/sheet/{sheet}")
async def get_sheet(
form_id: int,
sheet: str,
response: Response,
direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
sections = _parse_sections_csv(sections)
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.get(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
)
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),
)
@router.patch("/{form_id}/sheet/{sheet}/cell")
async def update_cell(
form_id: int,
sheet: str,
response: Response,
cell_body: CellPatch,
direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
sections = _parse_sections_csv(sections)
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
line_id=cell_body.line_id,
column=cell_body.column,
value=cell_body.value,
user=current_user,
)
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),
)
@router.patch("/{form_id}/sheet/{sheet}/cells")
async def update_cells(
form_id: int,
sheet: str,
response: Response,
cells_body: CellsPatch,
direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
sections = _parse_sections_csv(sections)
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.update_cells(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
changes=[c.model_dump() for c in cells_body.changes],
user=current_user,
)
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),
)
@router.post("/{form_id}/sheet/{sheet}/line")
async def add_line(
form_id: int,
sheet: str,
body: AddLineSchema,
response: Response,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, body.direction, None)
result = await sheet_service.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=body.expense_item_id,
item_id=body.item_id,
section_code=body.section_code,
direction=body.direction.value if body.direction else None,
name=body.name,
internal_order=body.internal_order,
vsp_id=body.vsp_id,
project_id=body.project_id,
justification=body.justification,
contract_number=body.contract_number,
contract_end_date=body.contract_end_date,
user=current_user,
)
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),
)
@router.delete("/{form_id}/sheet/{sheet}/line/{row_id}")
async def delete_line(
form_id: int,
sheet: str,
row_id: int,
response: Response,
direction: Optional[DirectionSchemaEnum] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
t0 = time.perf_counter()
sheet_service = SheetService(db)
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, None)
result = await sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=row_id,
direction=direction.value if direction else None,
user=current_user,
)
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),
)
@router.post("/")
async def add_form(
response: Response,
form: FormCreateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseListResponse[BudgetFormResponse]:
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
org_service = OrgUnitService(db)
org_units = await org_service.get_list(user=current_user, org_unit_id=form.org_unit_ids)
org_unit_ids = [o.id for o in org_units]
if len(org_units) != len(form.org_unit_ids):
not_found_ids = ", ".join(
[str(oid) for oid in set(form.org_unit_ids) - set(org_unit_ids)],
)
raise HTTPException(404, f"ССП {not_found_ids} не найдены")
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,
)
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],
count=len(new_forms),
)

126
api/src/api/v1/org_unit.py Normal file
View File

@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
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, OrgUnitCreateSchema, OrgUnitListSchema, OrgUnitSchema, OrgUnitUpdateSchema, ResponseBase
from src.services.org_unit_service import OrgUnitService
router = APIRouter(prefix="/org-unit", tags=["ssp"])
@router.get("/")
async def org_unit_list(
offset: int = 0,
limit: int = 100,
is_active: bool | None = None,
is_ssp: bool | None = None,
load_users_count: bool = False,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseListResponse[OrgUnitListSchema]:
org_service = OrgUnitService(db)
count, units = await org_service.get_list(
user=current_user,
with_count=True,
offset=offset,
limit=limit,
load_users=load_users_count,
is_active=is_active,
is_ssp=is_ssp,
)
return BaseListResponse(
result=[
OrgUnitListSchema.model_validate(unit) for unit in units
],
count=count,
)
@router.get("/{org_unit_id}")
async def org_unit_id(
org_unit_id: int,
load_users: bool = False,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
org_service = OrgUnitService(db)
unit = await org_service.get(
user=current_user,
org_unit_id=org_unit_id,
load_users=load_users,
)
if not unit:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
return BaseSingleResponse(
result = OrgUnitSchema.model_validate(unit)
)
@router.post("/")
async def create_ssp(
org_data: OrgUnitCreateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
"""Создание ССП/РФ."""
org_unit_service = OrgUnitService(db)
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(
message="ССП/РФ создана",
result=OrgUnitSchema.model_validate(org_unit),
)
@router.patch("/{org_unit_id}")
async def update_ssp(
org_unit_id: int,
org_data: OrgUnitUpdateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
"""Редактирование ССП/РФ."""
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.update(
org_unit_id=org_unit_id,
user=current_user,
**org_data.model_dump(exclude_unset=True),
)
if not org_unit:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
return BaseSingleResponse(
message="ССП/РФ обновлена",
result=OrgUnitSchema.model_validate(org_unit),
)
@router.delete("/{org_unit_id}")
async def delete_ssp(
org_unit_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> ResponseBase:
"""Удаление ССП/РФ."""
org_unit_service = OrgUnitService(db)
result = await org_unit_service.logical_delete(
org_unit_id=org_unit_id,
user=current_user,
is_active=True,
)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
return ResponseBase(success=True, message="ССП/РФ удалена")

290
api/src/api/v1/projects.py Normal file
View File

@ -0,0 +1,290 @@
import logging
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user_with_set_db
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import (
AddForm3LineBody,
AddProjectBody,
BaseListResponse,
BaseSingleResponse,
CellPatch,
CellsPatch,
ResponseBase,
SheetResponse,
UpdProjectBody,
)
from src.services.project_service import ProjectService
logger = logging.getLogger()
router = APIRouter(tags=["forms"])
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
def _parse_sections(sections: Optional[str]) -> Optional[list[str]]:
if not sections:
return None
parsed = [s.strip() for s in sections.split(",") if s.strip()]
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
if invalid:
raise HTTPException(400, f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
return parsed
def _rows_to_payload(rows: list[tuple]) -> list[dict]:
return [
{"row_type": row[0], "depth": row[1], "sort_order": row[2], "data": row[3]}
for row in rows
]
@router.get("/projects")
async def get_projects(
offset: int = 0,
limit: int = 100,
year: Optional[int] = None,
branch_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[dict]:
project_service = ProjectService(db)
count, projects = await project_service.get_list(
user=current_user,
with_count=True,
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
)
return BaseListResponse(result=projects, count=count)
@router.get("/projects/{project_id}")
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[dict]:
project_service = ProjectService(db)
project = await project_service.get(project_id=project_id, user=current_user)
if not project:
raise HTTPException(404, "Project not found")
return BaseSingleResponse(result=project)
@router.get("/projects/{project_id}/report/{year}/{report_type}")
async def get_project_report(
project_id: int,
year: int,
report_type: str,
response: Response,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
project_service = ProjectService(db)
t0 = time.perf_counter()
rows = await project_service.get_report_rows(
project_id=project_id,
year=year,
report_type=report_type,
sections=_parse_sections(sections),
user=current_user,
)
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))
@router.get("/rf-rollup/{branch_id}/{year}")
async def get_rf_rollup(
branch_id: int,
year: int,
response: Response,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
project_service = ProjectService(db)
t0 = time.perf_counter()
rows = await project_service.get_rf_rollup_rows(
branch_id=branch_id,
year=year,
sections=_parse_sections(sections),
user=current_user,
)
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))
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cell")
async def upd_form3_cell(
project_id: int,
year: int,
report_type: str,
body: CellPatch,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.upd_form3_cell(
project_id=project_id,
year=year,
report_type=report_type,
line_id=body.line_id,
column=body.column,
value=body.value,
user=current_user,
)
return _rows_to_payload(rows)
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cells")
async def upd_form3_cells(
project_id: int,
year: int,
report_type: str,
body: CellsPatch,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.upd_form3_cells(
project_id=project_id,
year=year,
report_type=report_type,
changes=[change.model_dump() for change in body.changes],
user=current_user,
)
return _rows_to_payload(rows)
@router.post("/projects/{project_id}/report/{year}/{report_type}/line")
async def add_form3_line(
project_id: int,
year: int,
report_type: str,
body: AddForm3LineBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.add_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
expense_item_id=body.expense_item_id,
user=current_user,
)
return _rows_to_payload(rows)
@router.delete("/projects/{project_id}/report/{year}/{report_type}/line/{line_id}")
async def del_form3_line(
project_id: int,
year: int,
report_type: str,
line_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.del_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
line_id=line_id,
user=current_user,
)
return _rows_to_payload(rows)
@router.patch("/project/{project_id}")
async def upd_project(
project_id: int,
body: UpdProjectBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
result = await project_service.upd_project(
project_id=project_id,
column=body.column,
value=body.value,
user=current_user,
)
return result
@router.delete("/projects/{project_id}")
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> ResponseBase:
project_service = ProjectService(db)
await project_service.delete_project(project_id=project_id, user=current_user)
return ResponseBase(success=True, message="Проект удалён")
@router.post("/projects")
async def add_project(
body: AddProjectBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
project_id, limit_report_id, current_expenses_report_id = await project_service.add_project(
name=body.name,
year=body.year,
org_unit_id=body.branch_id,
level=body.level,
parent_id=body.parent_id,
project_type=body.project_type,
vsp_format=body.vsp_format,
placement_type=body.placement_type,
object_address=body.object_address,
staff_count=body.staff_count,
total_area=body.total_area,
)
return {
"project_id": project_id,
"limit_report_id": limit_report_id,
"current_expenses_report_id": current_expenses_report_id,
}

View File

@ -1,7 +1,22 @@
from fastapi import APIRouter
from src.api.v1 import auth, users
from src.api.v1 import (
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp, expense_item
)
from src.api.v1 import websocket
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(admin.router)
api_router.include_router(audit.router)
api_router.include_router(forms.router)
api_router.include_router(projects.router)
api_router.include_router(form_phases.router)
api_router.include_router(export.router)
api_router.include_router(websocket.router)
api_router.include_router(org_unit.router)
api_router.include_router(vsp.router)
api_router.include_router(expense_item.router)

View File

@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip
from src.api.v1.deps import (
get_current_active_user,
require_admin,
require_executor_dfip,
)
from src.db.session import get_db
from src.domain.models import Users
from src.domain.schemas import User as UserSchema
from src.db.models.app_user import AppUser
from src.domain.schemas import BaseListResponse, BaseSingleResponse, User as UserSchema, UserAdminListResponse
from src.domain.schemas import (
SSPIDList,
ResponseBase,
@ -20,28 +24,48 @@ from src.services.user_service import UserService
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserSchema)
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
return current_user
@router.get("/me", response_model=UserAdminListResponse)
async def get_current_user_info(
current_user: AppUser = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
load_orgs: bool = False,
):
if load_orgs:
user_service = UserService(db)
result = await user_service.get(user_id=current_user.id, load_orgs=True)
return UserAdminListResponse.model_validate(result)
else:
return current_user
@router.get("/", response_model=UserListResponse)
@router.get("/")
async def get_users(
skip: int = 0,
limit: int = 100,
load_orgs: bool = False,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
):
current_user: AppUser = Depends(require_admin),
) -> BaseListResponse[UserAdminListResponse]:
user_service = UserService(db)
users = await user_service.get_all(current_user, skip=skip, limit=limit)
return UserListResponse(success=True, message="Список пользователей", result=users)
count, users = await user_service.get_all(
current_user,
skip=skip,
limit=limit,
load_orgs=load_orgs,
with_count=True
)
return BaseListResponse(
count=count,
result=[UserAdminListResponse.model_validate(user) for user in users]
)
@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse)
async def get_many_ssp(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_executor_dfip),
current_user: AppUser = Depends(require_executor_dfip),
):
user_service = UserService(db)
ssp_list = await user_service.get_many_ssp(user_id, current_user)
@ -57,7 +81,7 @@ async def set_many_ssp(
user_id: int,
ssp_ids_data: SSPIDList,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
@ -72,7 +96,7 @@ async def unset_many_ssp(
user_id: int,
ssp_ids_data: SSPIDList,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
success = await user_service.unset_many_ssp(
@ -84,39 +108,50 @@ async def unset_many_ssp(
)
@router.get("/{user_id}", response_model=UserSchema)
@router.get("/{user_id}")
async def get_user(
user_id: int,
load_orgs: bool = False,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
):
current_user: AppUser = Depends(require_admin),
) -> UserAdminListResponse:
user_service = UserService(db)
user = await user_service.get(user_id, current_user)
user = await user_service.get(
user_id=user_id,
current_user=current_user,
load_orgs=load_orgs,
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден",
)
return user
return UserAdminListResponse.model_validate(user)
@router.put("/", response_model=UserResponse)
@router.post("/", response_model=BaseSingleResponse[UserAdminListResponse])
async def create_user(
user_data: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.create_user(user_data=user_data, creator=current_user)
return UserResponse(success=True, message="Пользователь создан", result=user)
result = UserAdminListResponse.model_validate(user)
if user_data.load_orgs:
result.org_units = []
return BaseSingleResponse(success=True, message="Пользователь создан", result=result)
@router.patch("/{user_id}", response_model=UserResponse)
@router.patch("/{user_id}", response_model=BaseSingleResponse[UserAdminListResponse])
async def update_user(
user_id: int,
user_data: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.update_user(
@ -124,14 +159,15 @@ async def update_user(
user_data=user_data,
user=current_user,
)
return UserResponse(success=True, message="Пользователь обновлен", result=user)
result = UserAdminListResponse.model_validate(user)
return UserResponse(success=True, message="Пользователь обновлен", result=result)
@router.delete("/{user_id}", response_model=UserResponse)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
await user_service.delete_user(user_id=user_id, user=current_user)

185
api/src/api/v1/vsp.py Normal file
View File

@ -0,0 +1,185 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.api.v1.deps import require_admin, require_executor
from src.db.session import get_db
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
VSPCreate,
VSPExportRequest,
VSPInDB,
VSPUpdate,
)
from src.services.vsp_service import VSPService
router = APIRouter(prefix="/dict/info", tags=["info"])
@router.get("", response_model=BaseListResponse[VSPInDB])
async def get_vsp(
registration_number: str | None = None,
address: str | None = None,
ssp_id: int | None = None,
open_date_start: date | None = None,
open_date_end: date | 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),
):
"""Получение записей справочника INFO."""
service = VSPService(db)
result = await service.get_list(
user=current_user,
registration_number=registration_number,
address=address,
ssp_id=ssp_id,
open_date_start=open_date_start,
open_date_end=open_date_end,
placement_type=placement_type,
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
load_org_unit=True,
is_active=True,
with_count=True,
)
return BaseListResponse(
success=True,
message="Список INFO",
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
count=result[0]
)
@router.get("/dropdown", response_model=BaseListResponse[VSPInDB])
async def get_vsp_dropdown(
ssp_id: int | None = None,
form_id: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Получение записей для выпадающего списка INFO."""
service = VSPService(db)
result = await service.get_dropdown(user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True)
return BaseListResponse(
success=True,
message="Список INFO для выпадающего списка",
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
count=result[0],
)
@router.post("/export")
async def export_info(
body: VSPExportRequest,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Экспорт справочника INFO."""
service = VSPService(db)
stream, filename = await service.export_xlsx(
user=current_user,
registration_number=body.registration_number,
address=body.address,
ssp_ids=body.ssp_ids,
open_date_start=body.open_date_start,
open_date_end=body.open_date_end,
placement_type=body.placement_type,
staff_count_min=body.staff_count_min,
staff_count_max=body.staff_count_max,
)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("", response_model=BaseSingleResponse[VSPInDB], status_code=201)
async def add_vsp(
body: VSPCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
"""Добавление строки в справочник INFO."""
service = VSPService(db)
result = await service.create(
body,
current_user,
load_org_unit=True,
)
return BaseSingleResponse(
success=True,
message="Запись INFO создана",
result=VSPInDB.model_validate(result),
)
@router.get("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
async def get_vsp_by_id(
vsp_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Получение записи справочника INFO по ID."""
service = VSPService(db)
result = await service.get(vsp_id, user=current_user, load_org_unit=True)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ВСП не найден",
)
return BaseSingleResponse(
success=True,
message="Запись INFO",
result=VSPInDB.model_validate(result),
)
@router.put("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
async def update_vsp(
vsp_id: int,
body: VSPUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Редактирование строки справочника INFO."""
service = VSPService(db)
result = await service.update(
vsp_id,
body,
current_user,
load_org_unit=True,
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ВСП не найден",
)
return BaseSingleResponse(
success=True,
message="Запись INFO обновлена",
result=VSPInDB.model_validate(result),
)
@router.delete("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
async def delete_vsp(
vsp_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
"""Удаление строки справочника INFO."""
service = VSPService(db)
if await service.logical_delete(vsp_id, current_user):
return BaseSingleResponse(success=True, message="Запись INFO удалена")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ВСП не найден",
)

970
api/src/api/v1/websocket.py Normal file
View File

@ -0,0 +1,970 @@
import asyncio
from contextlib import asynccontextmanager
from dataclasses import asdict
import dataclasses
import enum
from json import dumps, loads
import logging
import time
from typing import Any, Optional
#
from asyncpg import UniqueViolationError
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
from src.db.models.app_user import AppUser
from src.db.models.form_type import FormTypeEnum
from src.services.budget_form_service import BudgetFormService
from src.services.project_service import ProjectService
from src.services.sheet_service import SheetService
from src.core.errors import BasicAppException, ValidationsError
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):
"""Контекстный менеджер для получения сессии базы данных."""
db = SessionLocal()
try:
if user_id is not None:
user_repo = UserRepository(db)
await user_repo.set_app_user_id(user_id=user_id)
yield db
finally:
await db.close()
router = APIRouter(prefix="/ws", tags=["websocket"])
@dataclasses.dataclass
class ConnectionInfo:
ws: WebSocket
user_id: int | None
@dataclasses.dataclass
class FormConnectionInfo(ConnectionInfo):
form_id: int | None = None
sheet: str | None = None
direction: str | None = None
@dataclasses.dataclass
class ProjectConnectionInfo(ConnectionInfo):
project_id: int | None = None
year: int | None = None
report_type: str | None = None
class ConnectionKeyEnum(str, enum.Enum):
FORM = "FORM"
PROJECT = "PROJECT"
class ConnectionManager:
con_info_mapping = {
ConnectionKeyEnum.FORM: FormConnectionInfo,
ConnectionKeyEnum.PROJECT: ProjectConnectionInfo,
}
def __init__(self):
self.connections: dict[int, dict[ConnectionKeyEnum, list[ConnectionInfo]]] = {
ConnectionKeyEnum.FORM: {},
ConnectionKeyEnum.PROJECT: {},
}
self.cell_locks: dict[Any, int] = {}
async def connect(
self,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
**kwargs,
# form_id: int,
# sheet: str,
# direction: str | None = None,
):
await websocket.accept()
key = frozenset(kwargs.items())
cls = self.con_info_mapping[con_key]
if key not in self.connections[con_key]:
self.connections[con_key][key] = [
cls(
ws=websocket,
user_id=None,
**kwargs,
)
]
else:
self.connections[con_key][key].append(
cls(
ws=websocket,
user_id=None,
**kwargs,
)
)
def set_user(
self,
websocket: WebSocket,
user_id: int,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
if con_info.ws == websocket:
con_info.user_id = user_id
async def disconnect(
self,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
code: int = status.WS_1008_POLICY_VIOLATION,
reason: str = "Ошибка",
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
if con_info.ws == websocket:
self.connections[con_key][key].remove(con_info)
if not len(self.connections[con_key][key]):
del self.connections[con_key][key]
self.release_locks_for_user(con_key=con_key, user_id=con_info.user_id, form_key=kwargs)
await websocket.close(
code=code, reason=reason,
)
async def broadcast_to_other(
self,
data: dict,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
data = data.copy()
data["is_self"] = False
message = dumps(data, default=_convert_error, ensure_ascii=False)
for con_info in self.connections[con_key][key]:
if con_info.ws != websocket:
try:
await con_info.ws.send_text(message)
except:
pass # Игнорируем недоступные соединения
async def broadcast_to_all(
self,
data: dict,
con_key: ConnectionKeyEnum,
websocket: WebSocket | None = None,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
data = data.copy()
if websocket is None:
data["is_self"] = None
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
self_message = None
else:
data["is_self"] = False
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
data["is_self"] = True
self_message = dumps(data, default=_convert_error, ensure_ascii=False)
for con_info in self.connections[con_key][key]:
try:
if websocket is not None and con_info.ws == websocket:
await con_info.ws.send_text(self_message)
else:
await con_info.ws.send_text(default_message)
except:
pass # Игнорируем недоступные соединения
async def send_back(self, data: dict, websocket: WebSocket):
data = data.copy()
data["is_self"] = True
message = dumps(data, default=_convert_error, ensure_ascii=False)
await websocket.send_text(message)
async def get_data(self, websocket: WebSocket) -> dict:
return loads(await websocket.receive_text())
def acquire_cell_lock(
self,
con_key: ConnectionKeyEnum,
user_id: int,
cell_key: dict,
form_key: dict,
) -> bool:
if not cell_key:
return False
cell_key = frozenset(cell_key.items())
lock_key = (con_key, frozenset(form_key.items()), cell_key)
lock_owner_id = self.cell_locks.get(lock_key)
if lock_owner_id is not None and lock_owner_id != user_id:
return False
self.cell_locks[lock_key] = user_id
return True
def release_cell_lock(
self,
con_key: ConnectionKeyEnum,
user_id: int,
cell_key: dict,
form_key: dict,
):
if not cell_key:
return
cell_key = frozenset(cell_key.items())
lock_key = (con_key, frozenset(form_key.items()), cell_key)
lock_owner_id = self.cell_locks.get(lock_key)
if lock_owner_id == user_id:
self.cell_locks.pop(lock_key, None)
def release_locks_for_user(
self,
con_key: ConnectionKeyEnum,
user_id: int,
form_key: dict
):
filter_key = (con_key, frozenset(form_key.items()))
lock_keys = [
lock_key
for lock_key, lock_owner_id in self.cell_locks.items()
if lock_key[:-1] == filter_key and lock_owner_id == user_id
]
for lock_key in lock_keys:
self.cell_locks.pop(lock_key, None)
def is_cell_available_for_edit(
self,
con_key: ConnectionKeyEnum,
user_id: int,
cell_key: dict,
form_key,
) -> bool:
if not cell_key:
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
def is_row_locked_by_other(
self,
con_key: ConnectionKeyEnum,
user_id: int,
line_id: int,
) -> bool:
if line_id is None:
return False
for (dict_con_key, form_key, cell_key), lock_owner_id in self.cell_locks.items():
if dict_con_key != con_key:
continue
if form_key != form_key or lock_owner_id == user_id:
continue
for k, v in cell_key:
if k == "line_id" and v == line_id:
return True
return False
def release_locks_for_row(
self,
con_key: ConnectionKeyEnum,
user_id: int,
line_id: int,
) -> bool:
if line_id is None:
return False
lock_keys = []
for lock_key, lock_owner_id in self.cell_locks.items():
dict_con_key, form_key, cell_key = lock_key
if dict_con_key != con_key:
continue
if form_key != form_key or lock_owner_id == user_id:
continue
for k, v in cell_key.items():
if k == "line_id" and v == line_id:
lock_keys.append(lock_key)
for lock_key in lock_keys:
self.cell_locks.pop(lock_key, None)
class FormEventProcess:
def __init__(self, db: AsyncSession):
self.sheet_service: SheetService = SheetService(db)
self.bf_service: BudgetFormService = BudgetFormService(db)
self.bl_service: BudgetLineService = BudgetLineService(db)
self.user_service: UserService = UserService(db)
async def process(
self,
event_data: dict,
form_id: int,
user_id: int,
sheet: str,
direction: str | None = None,
) -> int | bool | dict:
curr_user: AppUser = await self.user_service.get(user_id)
match event_data["event"]:
case "cell_updated":
return await self.__update_cell(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case "row_added":
return await self.__add_row(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case "row_deleted":
return await self.__del_row(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
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
async def __update_cell(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None = None,
) -> list[tuple]:
"""
event_data: {
"line_id": int,
"column": str,
"value": any,
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
return await self.sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=None,
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
user=user,
)
async def __add_row(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None,
) -> list[tuple]:
"""
data = {
expense_item_id: Optional[int] = None
item_id: Optional[str] = None
section_code: Optional[str] = None
name: Optional[str] = None
internal_order: Optional[str] = None
vsp_id: Optional[int] = None
project_id: Optional[int] = None
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
ids = await self.bl_service.get_ids(budget_form_id=form_id)
result = await self.sheet_service.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=event_data.get("expense_item_id"),
item_id=event_data.get("item_id"),
section_code=event_data.get("section_code"),
direction=direction,
name=event_data.get("name"),
internal_order=event_data.get("internal_order"),
vsp_id=event_data.get("vsp_id"),
project_id=event_data.get("project_id"),
justification=event_data.get("justification"),
contract_number=event_data.get("contract_number"),
contract_end_date=event_data.get("contract_end_date"),
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 __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,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None,
) -> list[tuple]:
"""
data = {
"row_id": int,
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
result = await self.sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=event_data["row_id"],
direction=direction,
user=user,
)
return result
class ProjectEventProcess:
def __init__(self, db: AsyncSession):
self.project_service: ProjectService = ProjectService(db)
self.prl_service: RfProjectReportLineService = RfProjectReportLineService(db)
self.pr_service: RfProjectReportService = RfProjectReportService(db)
self.user_service: UserService = UserService(db)
async def process(
self,
event_data: dict,
project_id: int,
user_id: int,
year: int,
report_type: str,
) -> int | bool | dict:
curr_user: AppUser = await self.user_service.get(user_id)
match event_data["event"]:
case "cell_updated":
return await self.__update_cell(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case "row_added":
return await self.__add_row(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case "row_deleted":
return await self.__del_row(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case _:
pass
async def __update_cell(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
event_data: {
"line_id": int,
"column": str,
"value": any,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.upd_form3_cell(
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
user=user,
)
async def __add_row(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
expense_item_id: Optional[int] = None
}
"""
project_report = await self.pr_service.get(
year=year,
report_type=report_type,
project_id=project_id,
)
if not project_report:
return None
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,
expense_item_id=event_data["expense_item_id"],
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,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
"row_id": int,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.del_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["row_id"],
user=user,
)
manager = ConnectionManager()
def _convert_error(o):
try:
return asdict(o)
except TypeError:
return o
async def login(websocket: WebSocket, **kwargs) -> int:
user_data = await manager.get_data(websocket=websocket)
if user_data.get("event") != "user_login":
return None
async with get_db_session() as db:
user = await get_user_by_token(token=user_data["data"].get("token"), db=db)
if not user:
return None
manager.set_user(
websocket=websocket,
user_id=user.id,
**kwargs,
)
return user.id
def resolve_cell_key(event_data: dict) -> dict | None:
data = event_data.get("data")
if not data or ("line_id" not in data and "line_id_code" not in data) or "column" not in data:
return None
return {
"line_id": data.get("line_id_code") or data.get("line_id"),
"column": data["column"],
}
async def process_websocket(
websocket: WebSocket,
processor_cls,
**kwargs,
):
await manager.connect(
websocket=websocket,
**kwargs,
)
kwargs_process = kwargs.copy()
if "con_key" in kwargs_process:
kwargs_process.pop("con_key")
con_key = kwargs.get("con_key")
try:
user_id = await login(
websocket=websocket,
**kwargs,
)
if user_id is None:
await manager.disconnect(
websocket=websocket,
reason="Ошибка авторизации",
**kwargs,
)
return
while True:
data = loads(await websocket.receive_text())
try:
event_name = data.get("event")
cell_key = resolve_cell_key(data)
match event_name:
case "cell_edit_start":
if 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
await manager.send_back(
data,
websocket,
)
await manager.broadcast_to_other(
data=data,
# user_id=user_id,
websocket=websocket,
con_key=con_key,
**kwargs_process,
)
continue
case "cell_edit_end":
manager.release_cell_lock(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
form_key=kwargs_process,
)
await manager.send_back(
data,
websocket,
)
await manager.broadcast_to_other(
data=data,
# user_id=user_id,
websocket=websocket,
con_key=con_key,
**kwargs_process,
)
continue
case "cell_updated":
if not manager.is_cell_available_for_edit(
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,
user_id=user_id,
line_id=data["data"]["row_id"]
):
data["error"] = (
"Нельзя удалить строку: в этой строке есть редактируемые ячейки"
)
await manager.send_back(
data,
websocket,
)
continue
case _:
pass
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(
con_key=con_key,
user_id=user_id,
line_id=data["data"]["row_id"]
)
if data.get("event") in [
"cell_updated",
"row_added",
"program_added",
"project_added",
"row_deleted",
]:
await manager.broadcast_to_all(
data=data,
websocket=websocket,
# user_id=user_id,
**kwargs
)
else:
await manager.broadcast_to_other(
data=data,
# user_id=user_id,
websocket=websocket,
**kwargs,
)
except BasicAppException as e:
data["error"] = e.description or "Неизвестная ошибка"
await manager.send_back(
data=data,
websocket=websocket,
)
except IntegrityError as e:
data["error"] = str(e)
await manager.send_back(
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)
handlers = websocket.app.exception_handlers
if tp in handlers:
data["error"] = loads((await handlers[tp](request=None, exc=e)).body)
await manager.send_back(
data=data,
websocket=websocket,
)
else:
raise e
except WebSocketDisconnect:
await manager.disconnect(
websocket=websocket,
**kwargs
)
except Exception as e:
# Логируем ошибку, но не бросаем HTTPException — это WebSocket
print(f"Error: {e}")
await manager.disconnect(
websocket=websocket,
**kwargs,
)
@router.websocket("/form/{form_id}/sheet/{sheet}")
async def websocket_form(
websocket: WebSocket,
form_id: int,
sheet: str,
direction: Optional[str] = None,
):
await process_websocket(
websocket=websocket,
form_id=form_id,
sheet=sheet,
direction=direction,
processor_cls=FormEventProcess,
con_key=ConnectionKeyEnum.FORM,
)
@router.websocket("/projects/{project_id}/report/{year}/{report_type}")
async def websocket_project(
websocket: WebSocket,
project_id: int,
year: int,
report_type: str,
):
await process_websocket(
websocket=websocket,
project_id=project_id,
year=year,
report_type=report_type,
processor_cls=ProjectEventProcess,
con_key=ConnectionKeyEnum.PROJECT,
)

31
api/src/core/CONSTANTS.py Normal file
View File

@ -0,0 +1,31 @@
VALIDATION_PREFIXES = (
"computed_field:",
"normative_field:",
"unknown_column:",
"key_field:",
"structural_field:",
"bad_column_format:",
"bad_booking_year:",
"bad_booking_quarter:",
"unsupported_scope:",
)
PG_404_ERRORS = (
"не существует",
"не принадлежит",
"not found",
)
PG_409_ERRORS = (
"unique",
"duplicate",
"foreign key",
)
PG_400_ERRORS = (
"violates check constraint",
)
PG_403_ERRORS = (
"permission denied",
"insufficient privilege",
"role_not_allowed:",
"window_closed:",
)

View File

@ -4,10 +4,7 @@ from typing import List
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
if os.environ.get("APP_ENV", "dev") == "test":
prefix = "OPENBAO__SETTINGS_TEST"
else:
prefix = "OPENBAO__SETTINGS"
prefix = "OPENBAO__SETTINGS"
class Settings(BaseSettings):
@ -105,6 +102,11 @@ class Settings(BaseSettings):
description="Размер батча для очистки аудита",
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
)
AUDIT_LOG_CLEANUP_INTERVAL_SECONDS: int = Field(
default=86400,
description="Интервал автозапуска очистки аудита в секундах",
alias="AUDIT_LOG_CLEANUP_INTERVAL_SECONDS",
)
model_config = SettingsConfigDict(
env_file=".env",

View File

@ -1,6 +1,8 @@
from pydantic import BaseModel, Field
class ValidationError(BaseModel):
field: str = Field(...)
description: str = Field(...)
@ -10,6 +12,14 @@ class BasicAppException(Exception):
description: str | None = None
class ValidationsError(BasicAppException):
errors: dict[str, list[str]]
def __init__(self, errors, *args):
super().__init__(*args)
self.errors = errors
class AccessDeniedException(BasicAppException):
description = "Доступ запрещен"

View File

@ -1,5 +1,7 @@
from fastapi import FastAPI, status
from fastapi import FastAPI, HTTPException, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
from src.core.errors import (
AccessDeniedException,
@ -7,41 +9,176 @@ from src.core.errors import (
UserNotFoundException,
UsernameConflictException,
ValidationException,
ValidationsError,
)
from src.core.CONSTANTS import (
VALIDATION_PREFIXES,
PG_404_ERRORS,
PG_409_ERRORS,
PG_400_ERRORS,
PG_403_ERRORS,
)
def _error_payload(code: int, message: str, field: str = "") -> dict:
"""Формирует структурированный payload ошибки по спеке."""
return {
"code": code,
"field": field,
"message": message,
}
def _parse_prefixed_validation_error(message: str) -> str:
for prefix in VALIDATION_PREFIXES:
if message.lower().startswith(prefix):
field = message.split(":", 1)[1].strip() if ":" in message else ""
return field or ""
return ""
def _first_db_message_line(message: str) -> str:
"""Возвращает первую непустую строку текста ошибки БД."""
for line in message.splitlines():
line = line.strip()
if line:
return line
return message.strip()
def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, int, str, str]:
"""Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field."""
raw = str(getattr(exc, "orig", exc) or exc).strip()
message = _first_db_message_line(raw) or "Database error"
low = message.lower()
if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
field = _parse_prefixed_validation_error(message)
return status_code, status_code, message, field
if any(error in low for error in PG_404_ERRORS):
status_code = status.HTTP_404_NOT_FOUND
return status_code, status_code, message, ""
if any(error in low for error in PG_403_ERRORS):
status_code = status.HTTP_403_FORBIDDEN
return status_code, status_code, message, ""
if isinstance(exc, IntegrityError):
if any(error in low for error in PG_409_ERRORS):
status_code = status.HTTP_409_CONFLICT
return status_code, status_code, message, ""
if any(error in low for error in PG_400_ERRORS):
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
if isinstance(exc, OperationalError):
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return status_code, status_code, "Database unavailable", ""
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
def map_http_exception(exc: HTTPException) -> tuple[int, int, str, str]:
"""Маппинг HTTPException в единый формат ошибки API."""
status_code = int(exc.status_code)
detail = exc.detail
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("detail") or "HTTP error")
field = detail.get("field")
field_str = str(field) if field is not None else ""
return status_code, status_code, message, field_str
message = str(detail) if detail else "HTTP error"
return status_code, status_code, message, ""
def map_request_validation_error(exc: RequestValidationError) -> tuple[int, int, str, str]:
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
errors = exc.errors()
if not errors:
return status_code, status_code, "Validation error", ""
first_error = errors[0]
message = str(first_error.get("msg") or "Validation error")
loc = first_error.get("loc") or ()
field = ".".join(str(part) for part in loc if part not in ("body", "query", "path", "header", "cookie"))
return status_code, status_code, message, field
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(request, exc: RequestValidationError):
status_code, code, message, field = map_request_validation_error(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc: HTTPException):
status_code, code, message, field = map_http_exception(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
headers=exc.headers,
)
@app.exception_handler(AccessDeniedException)
async def access_denied_exception_handler(request, exc: AccessDeniedException):
status_code = status.HTTP_403_FORBIDDEN
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description),
)
@app.exception_handler(ValidationException)
async def validation_exception_handler(request, exc: ValidationException):
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description, field=exc.field or ""),
)
@app.exception_handler(UserNotFoundException)
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
status_code = status.HTTP_404_NOT_FOUND
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description),
)
@app.exception_handler(UsernameConflictException)
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
status_code = status.HTTP_409_CONFLICT
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description),
)
@app.exception_handler(BasicAppException)
async def basic_app_exception_handler(request, exc: BasicAppException):
message = exc.description or "Application error"
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse(
status_code=status_code,
content=_error_payload(status_code, message),
)
@app.exception_handler(SQLAlchemyError)
@app.exception_handler(DBAPIError)
async def sqlalchemy_exception_handler(request, exc: SQLAlchemyError):
status_code, code, message, field = map_sqlalchemy_error(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
)
@app.exception_handler(ValidationsError)
async def validations_error_handler(request, exc: ValidationsError):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.description or "Application error"},
content=exc.errors,
)

View File

@ -7,12 +7,15 @@ connect_args = {}
if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False
engine = create_async_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
connect_args=connect_args,
)

View File

@ -0,0 +1,33 @@
from src.db.models.allocation import Allocation
from src.db.models.app_user import AppUser
from src.db.models.audit_log import AuditLog
from src.db.models.booking import Booking
from src.db.models.budget_form import BudgetForm
from src.db.models.budget_line import BudgetLine
from src.db.models.budget_line_quarter import BudgetLineQuarter
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, 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
from src.db.models.limit_template import LimitTemplate
from src.db.models.org_unit import OrgUnit
from src.db.models.phase_template import PhaseTemplate
from src.db.models.plan import Plan
from src.db.models.project import Project
from src.db.models.rent_detail import RentDetail
from src.db.models.reserve import Reserve
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.db.models.rf_project_report_quarter import RfProjectReportQuarter
from src.db.models.role import Role, UserRoleEnum
from src.db.models.security_detail import SecurityDetail
from src.db.models.sequestration import Sequestration
from src.db.models.user_org import UserOrg
from src.db.models.utility_detail import UtilityDetail
from src.db.models.vsp import Vsp

View File

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Allocation(Base):
__tablename__ = "allocation"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
internal_order: Mapped[str | None] = mapped_column(String)
property_object: Mapped[str | None] = mapped_column(String)
contract_ref: Mapped[str | None] = mapped_column(String)
allocation_purpose: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
Index(
"ix_v4_app_user_active",
"is_active",
postgresql_where="is_active",
),
Index("ix_v4_app_user_role", "role_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String)
username: Mapped[str] = mapped_column(String)
hashed_password: Mapped[str] = mapped_column(String)
full_name: Mapped[str | None] = mapped_column(String)
role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore
# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by"
# )
# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by"
# )
# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="creator",
# foreign_keys="RfProjectReport.created_by",
# )
# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="updater",
# foreign_keys="RfProjectReport.updated_by",
# )
audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
"AuditLog", back_populates="user"
)
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user")
org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True)

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
JSON,
String,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_v3_audit_log_dt", text("event_dt DESC")),
Index("ix_v3_audit_log_event", "event"),
Index(
"ix_v3_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v3_audit_log_user", "user_id"),
Index("ix_v4_audit_log_dt", text("event_dt DESC")),
Index("ix_v4_audit_log_event", "event"),
Index(
"ix_v4_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v4_audit_log_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.app_user.id", ondelete="SET NULL")
)
org_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.org_unit.id", ondelete="SET NULL")
)
task_id: Mapped[int | None] = mapped_column()
form_id: Mapped[int | None] = mapped_column()
event_dt: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
event: Mapped[str] = mapped_column(String)
event_type: Mapped[str] = mapped_column(String)
event_data: Mapped[dict | None] = mapped_column(JSON)
user: Mapped["AppUser | None"] = relationship(
"AppUser", back_populates="audit_logs"
)
# org_unit: Mapped["OrgUnit | None"] = relationship(
# "OrgUnit", back_populates="audit_logs"
# )

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Booking(Base):
__tablename__ = "booking"
__table_args__ = (
CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"),
CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"),
Index("ix_v3_booking_line", "line_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
quarter: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String)
booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric)
booked_amount_contract: Mapped[float | None] = mapped_column(Numeric)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,61 @@
from datetime import datetime
import typing
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.form_type import FormTypeEnum
from src.db.base import Base
class BudgetForm(Base):
__tablename__ = "budget_form"
__table_args__ = {"schema": "v3"}
id: Mapped[int] = mapped_column(primary_key=True)
form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore
String, ForeignKey("v3.form_type.code")
)
year: Mapped[int | None] = mapped_column(Integer)
created_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by")
)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, server_default=func.now()
)
updated_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime,
default=func.now(),
onupdate=func.now(),
)
org_unit_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
)
form_type: Mapped["FormType"] = relationship(
"FormType", #back_populates="budget_forms"
)
# creator: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by]
# )
# updater: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
# )
org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
"OrgUnit", #back_populates="budget_forms"
)
# budget_lines: Mapped[list["BudgetLine"]] = relationship(
# "BudgetLine", back_populates="budget_form"
# )
# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship(
# "FixedAssetReport", back_populates="budget_form"
# )
# form_phases: Mapped[list["FormPhase"]] = relationship(
# "FormPhase", back_populates="budget_form"
# )
# form_limits: Mapped[list["FormLimit"]] = relationship(
# "FormLimit", back_populates="budget_form"
# )

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLine(Base):
__tablename__ = "budget_line"
__table_args__ = (
Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"),
Index("ix_v3_budget_line_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id"))
expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
name: Mapped[str | None] = mapped_column(String)
internal_order: Mapped[str | None] = mapped_column(String)
vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
justification: Mapped[str | None] = mapped_column(String)
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
direction: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
# vsp = relationship("Vsp", back_populates="budget_lines")
# project = relationship("Project", back_populates="budget_lines")
# ckk = relationship("Ckk", uselist=False)
# collegial_approval = relationship("CollegialApproval", uselist=False)
# contract_detail = relationship("ContractDetail", uselist=False)
# contract_summary = relationship("ContractSummary", uselist=False)
# plan = relationship("Plan", uselist=False)
# reserve = relationship("Reserve", uselist=False)
# allocation = relationship("Allocation", uselist=False)
# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore
# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore
# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLineQuarter(Base):
__tablename__ = "budget_line_quarter"
__table_args__ = (
CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"),
{"schema": "v3"},
)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
adj_current: Mapped[float | None] = mapped_column(Numeric)
adj_ssp: Mapped[float | None] = mapped_column(Numeric)
adj_rf: Mapped[float | None] = mapped_column(Numeric)
adj_reserve: Mapped[float | None] = mapped_column(Numeric)
adj_comment: Mapped[str | None] = mapped_column(String)
target_change: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction_comment: Mapped[str | None] = mapped_column(String)
payment_date: Mapped[date | None] = mapped_column(Date)
payment_amount: Mapped[float | None] = mapped_column(Numeric)
payment_amount_ho: Mapped[float | None] = mapped_column(Numeric)
payment_amount_rf: Mapped[float | None] = mapped_column(Numeric)
payment_comment: Mapped[str | None] = mapped_column(String)
payment_act: Mapped[str | None] = mapped_column(String)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q2: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q3: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q4: Mapped[float | None] = mapped_column(Numeric)
transfer_to_economy: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric)
transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric)
transfer_next_comment: Mapped[str | None] = mapped_column(String)
transfer_far_comment: Mapped[str | None] = mapped_column(String)
booking_amount: Mapped[float | None] = mapped_column(Numeric)
plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric)
plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric)
plan_revision_increase: Mapped[float | None] = mapped_column(Numeric)
plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric)
plan_revision_comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", back_populates="quarters")

29
api/src/db/models/ckk.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Ckk(Base):
__tablename__ = "ckk"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
delivery_deadline: Mapped[str | None] = mapped_column(String)
procurement_plan: Mapped[str | None] = mapped_column(String)
procurement_method: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class CollegialApproval(Base):
__tablename__ = "collegial_approval"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
approved_amount: Mapped[float | None] = mapped_column(Numeric)
protocol_reference: Mapped[str | None] = mapped_column(String)
note: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractDetail(Base):
__tablename__ = "contract_detail"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
addenda: Mapped[str | None] = mapped_column(String)
subject: Mapped[str | None] = mapped_column(String)
currency: Mapped[str | None] = mapped_column(String)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
vat_rate: Mapped[str | None] = mapped_column(String)
exchange_rate: Mapped[float | None] = mapped_column(Numeric)
amount_foreign: Mapped[float | None] = mapped_column(Numeric)
deadline: Mapped[str | None] = mapped_column(String)
payment_scheme: Mapped[str | None] = mapped_column(String)
act: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
contract_date: Mapped[str | None] = mapped_column(Date)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractSummary(Base):
__tablename__ = "contract_summary"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
total_amount: Mapped[float | None] = mapped_column(Numeric)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
deadline: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
future_payments_y1: Mapped[float | None] = mapped_column(Numeric)
future_payments_y2: Mapped[float | None] = mapped_column(Numeric)
other_ssp_amount: Mapped[float | None] = mapped_column(Numeric)
centralized_flag: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,36 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class ExpenseItem(Base):
__tablename__ = "expense_item"
__table_args__ = (
CheckConstraint("direction IN ('Support', 'Development')", name="expense_item_direction_check"),
CheckConstraint("sheet IN ('AHR', 'CAP', 'OPER')", name="expense_item_sheet_check"),
Index("ix_v3_expense_item_parent", "parent_id"),
Index("ix_v3_expense_item_sheet", "sheet"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
section_code: Mapped[str | None] = mapped_column(String)
item_id: Mapped[str | None] = mapped_column(String)
num_group_id: Mapped[str | None] = mapped_column(String)
name: Mapped[str | None] = mapped_column(String)
sheet: Mapped[str | None] = mapped_column(String)
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

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormLimit(Base):
__tablename__ = "form_limit"
__table_args__ = (
Index("ix_v3_form_limit_form", "budget_form_id"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True)
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True)
qty_q1: Mapped[int | None] = mapped_column(Integer)
qty_q2: Mapped[int | None] = mapped_column(Integer)
qty_q3: Mapped[int | None] = mapped_column(Integer)
qty_q4: Mapped[int | None] = mapped_column(Integer)
comment: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm")
# template = relationship("LimitTemplate")

View File

@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class FormPhase(Base):
__tablename__ = "form_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_form_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_form_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_form_phase_window"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(
ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True
)
sheet: Mapped[str] = mapped_column(String, 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,79 @@
import enum
from sqlalchemy import CheckConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormTypeEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class FormType(Base):
__tablename__ = "form_type"
__table_args__ = (
CheckConstraint("scope IN ('GO','PD','RF')", name="ck_form_type_scope"),
CheckConstraint(
"storage_entity IN ('BUDGET_FORM','RF_PROJECT_REPORT')",
name="ck_form_type_storage_entity",
),
{"schema": "v3"},
)
code: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str | None] = mapped_column(String)
scope: Mapped[str | None] = mapped_column(String)
storage_entity: Mapped[str | None] = mapped_column(String)
SHEETS_BY_FORM_TYPE = {
FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"],
FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT",
"AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"],
FormTypeEnum.FORM_3: ["LIMIT", "CURRENT_EXPENSES"],
FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"],
}
SECTIONS_BY_FORM_TYPE = {
FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"],
FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"],
FormTypeEnum.FORM_3: ["q1", "q2", "q3", "q4", "year"],
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
}
_sheet_list_with_directions = {
FormTypeEnum.FORM_1: [
{
"sheet_name": sheet_name,
"direction": None,
} for sheet_name in ["OPER", "SMETA"]
] + [
{
"sheet_name": sheet_name,
"direction": direction
} for sheet_name in ["AHR", "CAP"] for direction in ["Support", "Development"]
]
}
@property
def sheet_list_with_directions(self) -> dict:
return self._sheet_list_with_directions.get(
self.code,
[
{
"sheet_name": sheet_name,
"direction": None,
} for sheet_name in self.sheet_list
]
)
@property
def sheet_list(self):
return self.SHEETS_BY_FORM_TYPE.get(self.code, [])
@property
def section_list(self):
return self.SECTIONS_BY_FORM_TYPE.get(self.code, [])

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class LimitTemplate(Base):
__tablename__ = "limit_template"
__table_args__ = (
CheckConstraint(
"row_type IN ('SECTION','GROUP','LEAF')",
name="ck_v3_limit_template_row_type",
),
Index("ix_v3_limit_template_parent", "parent_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id"))
sort_order: Mapped[int] = mapped_column(Integer)
row_type: Mapped[str] = mapped_column(String)
section_no: Mapped[str | None] = mapped_column(String)
expense_item_code: Mapped[str | None] = mapped_column(String)
name: Mapped[str] = mapped_column(String)
unit: Mapped[str | None] = mapped_column(String)
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id")
# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate")
# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit")

View File

@ -0,0 +1,42 @@
from __future__ import annotations
from sqlalchemy import Boolean, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class OrgUnit(Base):
__tablename__ = "org_unit"
__table_args__ = (
Index(
"ix_v4_org_unit_active",
"is_active",
postgresql_where="is_active",
),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
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:
return len(self.users) if self.users is not None else None
# budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="org_unit"
# )
# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
# "AuditLog", back_populates="org_unit"
# )
# projects: Mapped[list["Project"]] = relationship( # type: ignore
# "Project", back_populates="org_unit"
# )
vsps: Mapped[list["Vsp"]] = relationship("Vsp", back_populates="org_unit") # type: ignore
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="org_unit")
users: Mapped[list["AppUser"]] = relationship("AppUser", secondary="v3.user_org", back_populates="org_units")

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class PhaseTemplate(Base):
__tablename__ = "phase_template"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"),
{"schema": "v3"},
)
form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True)
sheet: Mapped[str] = mapped_column(String, 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=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")

20
api/src/db/models/plan.py Normal file
View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Plan(Base):
__tablename__ = "plan"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Project(Base):
__tablename__ = "project"
__table_args__ = (
CheckConstraint("level IN ('program', 'project')", name="project_level_check"),
CheckConstraint(
"placement_type IS NULL OR placement_type IN ('own', 'rent', 'other')",
name="project_placement_type_check",
),
CheckConstraint(
"project_type IS NULL OR project_type IN ('current', 'development')",
name="project_project_type_check",
),
CheckConstraint(
"vsp_format IS NULL OR vsp_format IN ('office', 'standalone', 'atm', 'other')",
name="project_vsp_format_check",
),
CheckConstraint(
text("name IS NOT NULL AND name <> ''"),
name="chk_v3_project_name",
),
Index("ix_v3_project_ssp", "org_unit_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str | None] = mapped_column(String)
level: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
project_type: Mapped[str | None] = mapped_column(String)
vsp_format: Mapped[str | None] = mapped_column(String)
placement_type: Mapped[str | None] = mapped_column(String)
object_address: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
# 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

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RentDetail(Base):
__tablename__ = "rent_detail"
__table_args__ = (
Index("ix_v3_rent_detail_line", "line_id"),
Index("ix_v3_rent_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Reserve(Base):
__tablename__ = "reserve"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
amount_q1: Mapped[float | None] = mapped_column(Numeric)
amount_q2: Mapped[float | None] = mapped_column(Numeric)
amount_q3: Mapped[float | None] = mapped_column(Numeric)
amount_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReport(Base):
__tablename__ = "rf_project_report"
__table_args__ = (
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
year: Mapped[int] = mapped_column(Integer)
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
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") # 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

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportLine(Base):
__tablename__ = "rf_project_report_line"
__table_args__ = (
UniqueConstraint(
"rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line"
),
Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id"))
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines")
# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem")
# quarters: Mapped[list[RfProjectReportQuarter]] = relationship(
# "RfProjectReportQuarter", back_populates="line"
# )

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportQuarter(Base):
__tablename__ = "rf_project_report_quarter"
__table_args__ = {"schema": "v3"}
rf_project_report_line_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True
)
quarter: Mapped[int] = mapped_column(
SmallInteger,
CheckConstraint("quarter >= 1 AND quarter <= 4"),
primary_key=True,
)
adj_by_items: Mapped[float | None] = mapped_column(Numeric)
adj_increase: Mapped[float | None] = mapped_column(Numeric)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
# line: Mapped[RfProjectReportLine] = relationship(
# "RfProjectReportLine", back_populates="quarters"
# )

31
api/src/db/models/role.py Normal file
View File

@ -0,0 +1,31 @@
import enum
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserRoleEnum(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Role(Base):
__tablename__ = "role"
__table_args__ = (
UniqueConstraint("code", name="uq_role_code"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String)
# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore
# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore
# "FormPhase", back_populates="role"
# )
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
# "PhaseTemplate", back_populates="role"
# )

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class SecurityDetail(Base):
__tablename__ = "security_detail"
__table_args__ = (
Index("ix_v3_security_detail_line", "line_id"),
Index("ix_v3_security_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Sequestration(Base):
__tablename__ = "sequestration"
__table_args__ = (
CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"),
UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"),
Index("ix_v3_sequestration_line_actor", "line_id", "actor"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
actor: Mapped[str] = mapped_column(String)
adj_q1: Mapped[float | None] = mapped_column(Numeric)
adj_q2: Mapped[float | None] = mapped_column(Numeric)
adj_q3: Mapped[float | None] = mapped_column(Numeric)
adj_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserOrg(Base):
__tablename__ = "user_org"
__table_args__ = (
UniqueConstraint("user_id", "org_unit_id"),
Index("ix_v4_user_org_org", "org_unit_id"),
Index("ix_v4_user_org_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE"))
org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE"))
user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin")
org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin")

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UtilityDetail(Base):
__tablename__ = "utility_detail"
__table_args__ = (
Index("ix_v3_utility_detail_line", "line_id"),
Index("ix_v3_utility_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

58
api/src/db/models/vsp.py Normal file
View File

@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Boolean, CheckConstraint, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Vsp(Base):
__tablename__ = "vsp"
__table_args__ = (
UniqueConstraint(
"reg_number", name="vsp_reg_number_unique",
),
CheckConstraint(
"closed_at IS NULL OR opened_at IS NULL OR closed_at >= opened_at",
name="chk_closed_after_opened",
),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
branch_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
reg_number: Mapped[str | None] = mapped_column(String)
address: Mapped[str | None] = mapped_column(String)
format: Mapped[str | None] = mapped_column(String)
opened_at: Mapped[date | None] = mapped_column(Date)
placement_type: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
closed_at: Mapped[date | None] = mapped_column(Date)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
#ToDo: а нужно ли нам и is_active и is_deleted?
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
system_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id"), nullable=True,
)
vsp_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
rent_contract_num: Mapped[str | None] = mapped_column(String, nullable=True)
rent_end_date : Mapped[date | None]= mapped_column(Date, nullable=True)
org_unit = relationship("OrgUnit", back_populates="vsps")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="vsp") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="vsp")
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="vsp")
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="vsp")

View File

@ -5,6 +5,8 @@ from alembic import command
from alembic.config import Config
from src.core.config import settings
from typing import AsyncGenerator
from src.db.base import SessionLocal
@ -12,6 +14,10 @@ async def get_db() -> AsyncGenerator:
db = SessionLocal()
try:
yield db
await db.commit()
except Exception:
await db.rollback()
raise
finally:
await db.close()

View File

@ -1,8 +1,23 @@
import enum
#ToDo: вероятно это теперь AuditEvent. А еще он нигде не юзается больше, вероятно, выпилить
class AuditEventType(str, enum.Enum):
LOGIN = "LOGIN"
USER_CREATE = "USER_CREATE"
USER_UPDATE = "USER_UPDATE"
USER_DELETE = "USER_DELETE"
ROW_CREATE = "ROW_CREATE"
ROW_DELETE = "ROW_DELETE"
ACCESS_WINDOW_CHANGE = "ACCESS_WINDOW_CHANGE"
ACCESS_EXTEND = "ACCESS_EXTEND"
USER_ROLE_CHANGE = "USER_ROLE_CHANGE"
CREATE_TASK = "CREATE_TASK"
UPDATE_TASK = "UPDATE_TASK"
DELETE_TASK = "DELETE_TASK"
VSP_CREATE = "VSP_CREATE"
VSP_UPDATE = "VSP_UPDATE"
PROJECT_CREATE = "PROJECT_CREATE"
PROJECT_DELETE = "PROJECT_DELETE"
USER_ACCESS_GRANTED = "USER_ACCESS_GRANTED"
USER_ACCESS_REVOKED = "USER_ACCESS_REVOKED"

View File

@ -1,70 +0,0 @@
import enum
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
UniqueConstraint)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from src.db.base import Base
class UserRole(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Roles(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
role = Column(String, nullable=False)
user_role = relationship("Users", back_populates="role", lazy="select")
class Users(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=True)
full_name = Column(String, nullable=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
role = relationship(
"Roles",
foreign_keys=[role_id],
back_populates="user_role",
lazy="selectin",
)
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
class SSP(Base):
__tablename__ = "ssp"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
class UserSSPLink(Base):
__tablename__ = "user_ssp_link"
__table_args__ = (
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")

View File

@ -1,41 +1,124 @@
from datetime import datetime
from typing import Optional
from datetime import datetime, date
import enum
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
from sqlalchemy.exc import MissingGreenlet
from pydantic import BaseModel, ConfigDict, EmailStr, Field
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
T = TypeVar("T")
ProjectTypeLiteral = Literal[
"Открытие ВСП",
"Закрытие ВСП",
"Переезд ВСП",
"Реновация РФ",
"Открытие УРМ",
]
VspFormatLiteral = Literal[
"Флагманский",
"Типовой",
"Розничный",
"МСБ",
"Лёгкий",
"Мини",
"Розничный-киоск",
"МБО",
"Офис самообслуживания",
"Другое",
]
PlacementTypeLiteral = Literal[
"Собственность",
"Аренда",
"Субаренда",
]
# def serialize_missing_greenlet(cls, obj):
# result = {}
# for attr in cls.model_fields:
# try:
# result[attr] = getattr(obj, attr)
# except MissingGreenlet:
# result[attr] = None
# return result
class ResponseBase(BaseModel):
success: Optional[bool] = Field(None)
message: Optional[str] = Field(None)
success: Optional[bool] = Field(True)
message: Optional[str] = None
class BaseSingleResponse(ResponseBase, Generic[T]):
result: Optional[T] = None
class BaseListResponse(ResponseBase, Generic[T]):
result: Optional[list[T]] = None
count: Optional[int] = None
class UserBase(BaseModel):
email: EmailStr
email: EmailStr = Field(..., examples=["user@example.com"])
username: str = Field(
...,
min_length=3,
max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$",
examples=["user_1"],
)
full_name: Optional[str] = Field(
None,
max_length=255,
pattern=r"^[^<>]*$",
examples=["Иван Иванов"],
)
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
role_id: int = 3
model_config = ConfigDict(from_attributes=True)
class UserCreate(UserBase):
password: str | None = None
password: str | None = Field(default=None, examples=["pass123"])
load_orgs: bool = Field(default=False)
model_config = ConfigDict(
json_schema_extra={
"example": {
"email": "user@example.com",
"username": "user_1",
"full_name": "Иван Иванов",
"role_id": 3,
"password": "pass123",
"load_orgs": True,
}
}
)
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
username: Optional[str] = Field(
None,
min_length=3,
max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$",
examples=["user_1"],
)
full_name: Optional[str] = Field(
None,
max_length=255,
pattern=r"^[^<>]*$",
examples=["Иван Иванов"],
)
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
role_id: Optional[int] = None
is_active: Optional[bool] = True
load_orgs: bool = Field(default=False)
class UserInDB(UserBase):
@ -51,6 +134,37 @@ class User(UserInDB):
pass
class UserAdminListResponse(UserInDB):
org_units: Optional[list["OrgUnitSchema"]] = None
@model_validator(mode='before')
@classmethod
def safe_orm_handler(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = {
"org_units": None,
"id": data.id,
"created_at": data.created_at,
"updated_at": data.updated_at,
"is_active": data.is_active,
"email": data.email,
"username": data.username,
"full_name": data.full_name,
"role_id": data.role_id,
}
try:
result["org_units"] = [
ou for ou in data.org_units
if ou.is_active
]
except MissingGreenlet:
pass
return result
class UserResponse(ResponseBase):
result: Optional[UserInDB] = None
@ -97,3 +211,465 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str
class FormTypeSchemaEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class FormCreateSchema(BaseModel):
year: int
form_type_code: FormTypeSchemaEnum
org_unit_ids: list[int] = Field(..., min_length=1)
class OrgUnitBaseSchema(BaseModel):
title: str
is_ssp: bool
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)
class OrgUnitResponseSchema(OrgUnitBaseSchema):
id: int
is_active: bool
utc_offset: str
users_count: int | None = None
model_config = ConfigDict(from_attributes=True)
@model_validator(mode='before')
@classmethod
def safe_orm_handler(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = {
"users_count": None,
"id": data.id,
"title": data.title,
"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
except MissingGreenlet:
pass
return result
class OrgUnitListSchema(OrgUnitResponseSchema):
pass
class OrgUnitSchema(OrgUnitResponseSchema):
users: list[UserInDB] | None = None
@model_validator(mode='before')
@classmethod
def safe_orm_handler(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = super().safe_orm_handler(data)
result["users"] = None
try:
result["users"] = data.users
except MissingGreenlet:
pass
return result
class BudgetFormResponse(BaseModel):
id: int
created_at: datetime
created_by: int | None
updated_at: datetime
form_type_code: FormTypeSchemaEnum
year: int
org_unit_id: int
org_unit: OrgUnitSchema | None = None
model_config = ConfigDict(from_attributes=True)
class FullSheetSchema(BaseModel):
sheet_name: str
direction: str | None = None
class SheetFormTypeResponse(BaseModel):
form_type: FormTypeSchemaEnum
sheets: list[str]
all_sheets: list[FullSheetSchema] | None = None
class DirectionSchemaEnum(str, enum.Enum):
SUPPORT = "Support"
DEVELOPMENT = "Development"
class SheetResponse(BaseModel):
row_type: str
depth: int
data: dict
sort_order: int | None = None
class CellPatch(BaseModel):
line_id: int
column: str
value: Any = None
class CellsPatch(BaseModel):
changes: list[CellPatch]
class ExportBulkRequest(BaseModel):
form_ids: list[int] = Field(min_length=1)
skip_failed: bool = True
class AddForm3LineBody(BaseModel):
expense_item_id: int
class UpdProjectBody(BaseModel):
column: str
value: Any = None
class AddProjectBody(BaseModel):
name: str = Field(
...,
max_length=30,
pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
)
year: int = Field(default_factory=lambda: datetime.now().year)
branch_id: int
level: Literal["project", "program"] = "project"
parent_id: Optional[int] = None
ssp_id: Optional[int] = None
project_type: Optional[ProjectTypeLiteral] = None
vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
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
item_id: Optional[str] = None
section_code: Optional[str] = None
direction: Optional[DirectionSchemaEnum] = None
name: Optional[str] = None
internal_order: Optional[str] = None
vsp_id: Optional[int] = None
project_id: Optional[int] = None
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None
class FormPhaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
budget_form_id: int
sheet: str
phase_code: str
role: str
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseCreate(BaseModel):
sheet: str
phase_code: str
role: FormPhaseRole
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseUpdate(BaseModel):
role: FormPhaseRole | None = None
column_keys: list[str] | None = None
opens_at: datetime | None = None
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)."""
entity: str = Field(..., description="Тип сущности")
entity_id: Optional[int] = Field(None, description="ID сущности")
action: str = Field(..., description="Действие")
payload_json: Optional[Dict[str, Any]] = None
class AuditLogInDB(AuditLogBase):
"""Схема записи аудита в базе данных."""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="ID записи")
user_id: Optional[int] = Field(None, description="ID пользователя")
at: datetime = Field(..., description="Дата/время события")
class AuditLog(AuditLogInDB):
"""Схема записи аудита для ответа API."""
user: Optional[User] = None
class AuditLogListResponse(ResponseBase):
"""Схема всех записей аудита для ответа API."""
result: List[AuditLog] = Field(..., description="Вывод записей аудита")
class AuditLogQueryParams(BaseModel):
"""Query-параметры для фильтрации журнала аудита."""
page: int = Field(1, ge=1, description="Номер страницы")
limit: int = Field(
20, ge=1, le=100, description="Количество записей на странице"
)
user_id: Optional[int] = Field(None, description="ID пользователя")
org_unit_id: Optional[int] = Field(None, description="ID ССП")
task_id: Optional[int] = Field(None, description="ID задачи")
form_id: Optional[int] = Field(None, description="ID формы")
event_type: Optional[str] = Field(None, description="Тип события")
event: Optional[str] = Field(None, description="Cобытиу")
date_from: Optional[date] = Field(None, description="Дата начала (ISO 8601)")
date_to: Optional[date] = Field(None, description="Дата окончания (ISO 8601)")
class VSPBase(BaseModel):
system_code: Optional[str] = Field(None, description="Системный код ВСП")
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
registration_number: str = Field(
...,
description="Значение для выпадающего списка",
validation_alias='reg_number',
serialization_alias='registration_number',
)
address: str = Field(..., description="Автоподставляемый адрес")
open_date: Optional[date] = Field(
None,
description="Дата открытия",
validation_alias='opened_at',
serialization_alias='open_date',
)
close_date: Optional[date] = Field(
None,
description="Дата закрытия",
validation_alias='closed_at',
serialization_alias='close_date',
)
approved_format: Optional[str] = Field(
None,
description="Утвержденный формат",
validation_alias='format',
serialization_alias='approved_format',
)
notes: Optional[str] = Field(None, description="Примечания")
placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность")
area: Optional[float] = Field(
None,
description="Арендная площадь",
validation_alias='total_area',
serialization_alias='area',
)
rent_contract_num: Optional[str] = Field(None, description="Номер договора аренды")
rent_end_date: Optional[date] = Field(
None, description="Срок окончания договора аренды"
)
ssp_id: int = Field(
...,
description="ID ССП",
validation_alias="branch_id",
serialization_alias="ssp_id",
)
class VSPEditBase(BaseModel):
system_code: Optional[str] = Field(None, description="Системный код ВСП")
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
reg_number: str = Field(
...,
description="Значение для выпадающего списка",
validation_alias='registration_number',
serialization_alias='reg_number',
)
address: str = Field(..., description="Автоподставляемый адрес")
opened_at: Optional[date] = Field(
None,
description="Дата открытия",
validation_alias='open_date',
serialization_alias='opened_at',
)
closed_at: Optional[date] = Field(
None,
description="Дата закрытия",
validation_alias='close_date',
serialization_alias='closed_at',
)
format: Optional[str] = Field(
None,
description="Утвержденный формат",
validation_alias='approved_format',
serialization_alias='format',
)
notes: Optional[str] = Field(None, description="Примечания")
placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность")
total_area: Optional[float] = Field(
None,
description="Арендная площадь",
validation_alias='area',
serialization_alias='total_area',
)
rent_contract_num: Optional[str] = Field(None, description="Номер договора аренды")
rent_end_date: Optional[date] = Field(
None, description="Срок окончания договора аренды"
)
branch_id: int = Field(
...,
description="ID ССП",
validation_alias="ssp_id",
serialization_alias="branch_id",
)
class VSPCreate(VSPEditBase):
pass
class VSPUpdate(VSPEditBase):
reg_number: str = Field(
None,
description="Значение для выпадающего списка",
validation_alias='registration_number',
serialization_alias='reg_number',
)
address: str = Field(None, description="Автоподставляемый адрес")
branch_id: int = Field(
None,
description="ID ССП",
validation_alias="ssp_id",
serialization_alias="branch_id",
)
class VSPInDB(VSPBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
is_deleted: bool = False
created_by: int
updated_by: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
regional_branch: Optional[str] = Field(None, description="Региональный филиал")
@model_validator(mode='before')
@classmethod
def get_regional_branch(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = vars(data)
result["regional_branch"] = None
try:
result["regional_branch"] = data.org_unit.title
except MissingGreenlet:
pass
return result
class VSPExportRequest(BaseModel):
registration_number: Optional[str] = None
address: Optional[str] = None
ssp_ids: Optional[List[int]] = None
open_date_start: Optional[date] = None
open_date_end: Optional[date] = None
placement_type: Optional[str] = None
staff_count_min: Optional[int] = None
staff_count_max: Optional[int] = None
class ExpenseItemBaseSchema(BaseModel):
section_code: Optional[str] = None
item_id: Optional[str] = None
num_group_id: Optional[str] = None
name: Optional[str] = None
sheet: Optional[str] = None
direction: Optional[str] = None
parent_id: Optional[int] = None
depth: Optional[int] = Field(default=0)
model_config = {
"from_attributes": True
}
class ExpenseItemResponseSchema(ExpenseItemBaseSchema):
id: int

View File

@ -1,9 +1,16 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from contextlib import suppress
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_swagger_ui_html
from starlette.exceptions import HTTPException as StarletteHTTPException
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@ -14,31 +21,104 @@ from src.core.exception_handlers import register_exception_handlers
from src.db.base import engine
from src.db.session import create_tables
if not settings.DEBUG:
from raisa_fastapi_protected_api import (
AuthorizationMiddleware,
OpenEndpoint,
ProtectedOAuthSettings,
ProtectedRolesSettings,
ProtectedSettings,
SearchType,
)
logging.basicConfig(level=logging.INFO)
# if not settings.DEBUG:
# from raisa_fastapi_protected_api import (
# AuthorizationMiddleware,
# OpenEndpoint,
# ProtectedOAuthSettings,
# ProtectedRolesSettings,
# ProtectedSettings,
# SearchType,
# )
_AUDIT_CLEANUP_LOCK_KEY = 21987431
async def _cleanup_audit_log_once() -> int:
if "postgresql" not in settings.DATABASE_URL:
return 0
total_deleted = 0
async with engine.begin() as conn:
lock_ok = (
await conn.execute(
text("SELECT pg_try_advisory_lock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
).scalar_one()
if not lock_ok:
return 0
try:
while True:
deleted = (
await conn.execute(
text(
"""
WITH doomed AS (
SELECT id
FROM v3.audit_log
WHERE event_dt < now() - make_interval(days => :retention_days)
ORDER BY id
LIMIT :batch_size
)
DELETE FROM v3.audit_log a
USING doomed d
WHERE a.id = d.id
RETURNING a.id
"""
),
{
"retention_days": settings.AUDIT_LOG_RETENTION_DAYS,
"batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE,
},
)
).rowcount
if not deleted:
break
total_deleted += deleted
finally:
await conn.execute(
text("SELECT pg_advisory_unlock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
return total_deleted
async def _audit_cleanup_loop() -> None:
# Первый прогон сразу после старта, дальше — по интервалу.
while True:
try:
await _cleanup_audit_log_once()
except Exception:
pass
await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS))
@asynccontextmanager
async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None
if not settings.DEBUG:
await create_tables()
ProtectedSettings(
ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
ProtectedRolesSettings(
APP_NAMESPACE=settings.APP_NAMESPACE,
APP_NAME=settings.APP_NAME,
),
)
# ProtectedSettings(
# ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
# ProtectedRolesSettings(
# APP_NAMESPACE=settings.APP_NAMESPACE,
# APP_NAME=settings.APP_NAME,
# ),
# )
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try:
yield
finally:
if cleanup_task:
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
await engine.dispose()
@ -69,32 +149,77 @@ async def healthcheck2():
"port": settings.PORT,
}
@app.get("/healthz", status_code=200)
async def healthz():
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
if not settings.DEBUG:
app.add_middleware(
AuthorizationMiddleware,
open_endpoints=[
OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
OpenEndpoint(
path=settings.ROOT_PATH + "/healthcheck",
type_search=SearchType.ABSOLUTE,
),
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/openapi.json",
type_search=SearchType.START,
),
OpenEndpoint(path="/docs", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/docs",
type_search=SearchType.START,
),
],
)
@app.get("/readyz")
async def readyz(response: Response):
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
if "postgresql" in settings.DATABASE_URL:
my_exists = (
await conn.execute(
text(
"""
SELECT 1
FROM pg_matviews
WHERE schemaname = 'v3'
AND matviewname = 'mv_expense_item_tree'
"""
)
)
).scalar_one_or_none()
if my_exists is None:
result["status"] = "fail"
result["mv_expense_item_tree"] = "missing"
response.status_code = 503
except SQLAlchemyError as exc:
result["status"] = "fail"
result["db"] = f"error: {exc.__class__.__name__}"
response.status_code = 503
return result
# if not settings.DEBUG:
# app.add_middleware(
# AuthorizationMiddleware,
# open_endpoints=[
# OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/healthcheck",
# type_search=SearchType.ABSOLUTE,
# ),
# OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/healthz",
# type_search=SearchType.ABSOLUTE,
# ),
# OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/readyz",
# type_search=SearchType.ABSOLUTE,
# ),
# OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/openapi.json",
# type_search=SearchType.START,
# ),
# OpenEndpoint(path="/docs", type_search=SearchType.START),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/docs",
# type_search=SearchType.START,
# ),
# ],
# )
app.add_middleware(
CORSMiddleware,
@ -108,4 +233,43 @@ app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
os.makedirs("web", exist_ok=True)
app.include_router(api_router, prefix="/api/v1")
@app.get("/docs-local", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=f"{settings.ROOT_PATH}{app.openapi_url}", # Путь к OpenAPI схеме (обычно /openapi.json)
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
# Указываем пути к локальным файлам
swagger_js_url=f"{settings.ROOT_PATH}/back-static/swagger-ui-bundle.js",
swagger_css_url=f"{settings.ROOT_PATH}/back-static/swagger-ui.css",
)
def sep_static(path, stat_dir):
sep_path = path.split("/")
index = sep_path.index(stat_dir)
return "/".join(sep_path[index:])
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except (HTTPException, StarletteHTTPException) as ex:
if ex.status_code == 404:
if "images" in path:
return await super().get_response(sep_static(path, "images"), scope)
if "static" in path:
return await super().get_response(sep_static(path, "static"), scope)
return await super().get_response("index.html", scope)
else:
raise ex
app.mount("/back-static", StaticFiles(directory="back_static"), name="back-static")
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
app.mount("/", StaticFiles(directory="web", html=True), name="web")

View File

@ -0,0 +1,87 @@
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import func
from sqlalchemy.orm import joinedload
# from sqlalchemy.orm import selectinload
# from app.domain.models import AuditLog
from src.db.models.audit_log import AuditLog
class AuditLogRepository:
"""Репозиторий для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
return (
(
await self.db.execute(
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
)
)
.scalars()
.first()
)
async def get_all(
self,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
event: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
query = select(AuditLog)
query_count = select(func.count(AuditLog.id))
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
query_count = query_count.where(AuditLog.user_id == user_id)
if org_unit_id is not None:
query = query.where(AuditLog.org_unit_id == org_unit_id)
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
if task_id is not None:
query = query.where(AuditLog.task_id == task_id)
query_count = query_count.where(AuditLog.task_id == task_id)
if form_id is not None:
query = query.where(AuditLog.form_id == form_id)
query_count = query_count.where(AuditLog.form_id == form_id)
if event_type is not None:
query = query.where(AuditLog.event_type == event_type)
query_count = query_count.where(AuditLog.event_type == event_type)
if event is not None:
query = query.where(AuditLog.event == event)
query_count = query_count.where(AuditLog.event == event)
if date_from is not None:
query = query.where(AuditLog.event_dt >= date_from)
query_count = query_count.where(AuditLog.event_dt >= date_from)
if date_to is not None:
query = query.where(AuditLog.event_dt <= date_to)
query_count = query_count.where(AuditLog.event_dt <= date_to)
query = query.options(joinedload(AuditLog.user))
query = query.order_by(AuditLog.event_dt.desc())
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
return (await self.db.execute(query)).scalars().all(), (
await self.db.execute(query_count)
).scalar_one()

View File

@ -0,0 +1,132 @@
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
class BudgetFormRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
limit: int | None = None,
org_unit: int | list[int] | None = None,
with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if with_count:
query = select(func.count().over().label("total_count"), BudgetForm)
else:
query = select(BudgetForm)
where = []
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))
query = query.where(*where)
if load_org:
query = query.options(
joinedload(BudgetForm.org_unit)
)
query = query.order_by(BudgetForm.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(BudgetForm.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()
async def get(
self,
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 = []
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(
joinedload(BudgetForm.org_unit)
)
if load_form_type:
query = query.options(
joinedload(BudgetForm.form_type)
)
return (await self.db.execute(query)).scalars().first()
async def create(
self,
form_type_code: str,
year: int,
org_unit_id: int,
) -> BudgetForm | None:
form_id = (
await self.db.execute(
text(
"""
SELECT *
FROM v3.add_budget_form(
:form_type,
:year,
:org_unit_id
)
"""
),
{
"form_type": form_type_code,
"year": year,
"org_unit_id": org_unit_id,
}
)
).scalar_one_or_none()
if form_id is None:
return None
return await self.get(
budget_form_id=form_id,
)

View File

@ -0,0 +1,26 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_line import BudgetLine
class BudgetLineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, budget_line_id: int) -> BudgetLine | None:
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]) -> list[BudgetLine]:
query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids))
return (await self.db.execute(query)).scalars().all()
async def get_ids(
self,
budget_form_id: int,
) -> list[int]:
query = select(BudgetLine.id).where(BudgetLine.budget_form_id == budget_form_id)
return (await self.db.execute(query)).scalars().all()

View File

@ -0,0 +1,43 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType
class ExpenseItemRepository:
def __init__(self, db: AsyncSession):
self.db = db
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

@ -0,0 +1,123 @@
from datetime import datetime
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_phase import FormPhase
class FormPhaseRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
budget_form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
query = query.where(FormPhase.sheet == sheet)
if phase_code is not None:
query = query.where(FormPhase.phase_code == phase_code)
return (await self.db.execute(query)).scalars().all()
async def get(
self, budget_form_id: int, sheet: str, phase_code: str
) -> FormPhase | None:
query = (
select(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.limit(1)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def create(
self,
budget_form_id: int,
sheet: str,
phase_code: str,
role: str,
column_keys: list[str],
opens_at: datetime,
closes_at: datetime,
) -> FormPhase:
result = await self.db.execute(
text(
"""
SELECT
(v3.add_form_phase(
:budget_form_id,
:sheet,
:phase_code,
:role,
:column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).budget_form_id
"""
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"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(budget_form_id, sheet, phase_code)
async def update(
self, budget_form_id: int, sheet: str, phase_code: str, data: dict
) -> FormPhase | None:
result = await self.db.execute(
text(
"""
SELECT
(v3.upd_form_phase(
:budget_form_id,
:sheet,
:phase_code,
:role,
:column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).budget_form_id
"""
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"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(budget_form_id, sheet, phase_code)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str
) -> bool:
result = await self.db.execute(
text(
"SELECT v3.del_form_phase(:budget_form_id, :sheet, :phase_code)"
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"phase_code": phase_code,
},
)
deleted = result.scalar_one_or_none()
return bool(deleted)

View File

@ -0,0 +1,138 @@
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit
from sqlalchemy.orm import joinedload
class OrgUnitRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
limit: int | None = None,
is_ssp: bool | None = None,
is_active: bool | None = None,
org_unit_id: int | list[int] | None = None,
with_count: bool = False,
load_users: bool = False,
) -> list[OrgUnit] | tuple[int, list[OrgUnit]]:
if with_count:
query = select(func.count().over().label("total_count"), OrgUnit)
else:
query = select(OrgUnit)
where = []
if is_ssp is not None:
where.append(OrgUnit.is_ssp == is_ssp)
if is_active is not None:
where.append(OrgUnit.is_active == is_active)
if org_unit_id is not None:
if isinstance(org_unit_id, int):
where.append(OrgUnit.id == org_unit_id)
else:
where.append(OrgUnit.id.in_(org_unit_id))
query = query.where(*where)
if load_users:
query = query.options(
joinedload(OrgUnit.users)
)
query = query.order_by(OrgUnit.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)).unique().all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(OrgUnit.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().unique().all()
async def get(
self,
org_unit_id: int,
load_users: bool = False
) -> OrgUnit | None:
query = select(OrgUnit).where(OrgUnit.id == org_unit_id)
if load_users:
query = query.options(
joinedload(OrgUnit.users)
)
return (await self.db.execute(query)).scalars().first()
async def create(
self,
title: str,
is_ssp: bool,
utc_offset: str,
) -> OrgUnit:
"""Создание 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)
return ssp
async def update(
self, org_unit: OrgUnit, **kwargs,
) -> OrgUnit | None:
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
async def logical_delete(self, org_unit_id: int, is_active: bool | None = None) -> bool:
"""Логическое удаление SSP."""
query = update(OrgUnit).where(OrgUnit.id == org_unit_id)
if is_active is not None:
query = query.where(OrgUnit.is_active == is_active)
query = query.values(is_active=False)
res = await self.db.execute(query)
await self.db.flush()
return res.rowcount > 0

View File

@ -0,0 +1,397 @@
import json
from sqlalchemy import exists, false, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit
from src.db.models.project import Project
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
class ProjectRepository:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
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,
"level": project.level,
"parent_id": project.parent_id,
"project_type": project.project_type,
"vsp_format": project.vsp_format,
"placement_type": project.placement_type,
"object_address": project.object_address,
"staff_count": project.staff_count,
"total_area": float(project.total_area) if project.total_area is not None else None,
"org_unit_id": project.org_unit_id,
"org_unit_name": org_unit_name,
"created_at": project.created_at.isoformat() if project.created_at else None,
"report_count": int(report_count),
"years": years,
}
@staticmethod
def _apply_project_filters(query, year: int | None, branch_id: int | None, org_unit_ids: list[int] | None):
if year is not None:
query = query.where(
exists(
select(1).where(
RfProjectReport.project_id == Project.id,
RfProjectReport.year == year,
)
)
)
if branch_id is not None:
query = query.where(Project.org_unit_id == branch_id)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
query = query.where(false())
else:
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return query
async def get_list(
self,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
org_unit_ids: list[int] | None = None,
) -> list[dict] | tuple[int, list[dict]]:
report_count_subq = (
select(func.count(RfProjectReport.id))
.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)
.order_by(Project.id)
)
query = self._apply_project_filters(query, year, branch_id, org_unit_ids)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
rows = (await self.db.execute(query)).all()
payload = [self._serialize_project(row[0], row[1], row[2], row[3]) for row in rows]
if not with_count:
return payload
count_query = select(func.count(Project.id))
count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids)
total = int((await self.db.execute(count_query)).scalar() or 0)
return total, payload
async def get(
self,
project_id: int,
org_unit_ids: list[int] | None = None,
) -> dict | None:
report_count_subq = (
select(func.count(RfProjectReport.id))
.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)
)
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))
row = (await self.db.execute(query)).first()
if not row:
return None
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 = (
select(func.count(RfProjectReportLine.id))
.where(RfProjectReportLine.rf_project_report_id == RfProjectReport.id)
.scalar_subquery()
)
query = (
select(
RfProjectReport.id,
RfProjectReport.year,
RfProjectReport.report_type,
RfProjectReport.created_by,
RfProjectReport.created_at,
RfProjectReport.updated_by,
RfProjectReport.updated_at,
line_count_subq.label("line_count"),
)
.where(RfProjectReport.project_id == project_id)
.order_by(RfProjectReport.year, RfProjectReport.report_type)
)
rows = (await self.db.execute(query)).all()
reports: list[dict] = []
for row in rows:
reports.append(
{
"id": row[0],
"year": row[1],
"report_type": row[2],
"created_by": row[3],
"created_at": row[4].isoformat() if row[4] is not None else None,
"updated_by": row[5],
"updated_at": row[6].isoformat() if row[6] is not None else None,
"line_count": int(row[7]),
}
)
return reports
async def resolve_report_id(
self,
project_id: int,
year: int,
report_type: str,
org_unit_ids: list[int] | None = None,
) -> int | None:
query = (
select(RfProjectReport.id)
.join(Project, Project.id == RfProjectReport.project_id)
.where(
Project.id == project_id,
RfProjectReport.year == year,
RfProjectReport.report_type == report_type,
)
)
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_report_rows(self, report_id: int, sections: list[str] | None = None) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_form3_report_jsonb(CAST(:report_id AS INT), CAST(:sections AS TEXT[]))
"""
)
return (await self.db.execute(query, {"report_id": report_id, "sections": sections})).all()
async def get_rf_rollup_rows(
self,
branch_id: int,
year: int,
sections: list[str] | None = None,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_form3_rf_rollup_jsonb(CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[]))
"""
)
return (
await self.db.execute(
query,
{"branch_id": branch_id, "year": year, "sections": sections},
)
).all()
async def upd_form3_cell(
self,
report_id: int,
line_id: int,
column: str,
value,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.upd_form3_cell(
CAST(:report_id AS INT),
CAST(:line_id AS INT),
CAST(:column AS TEXT),
CAST(:value AS JSONB)
)
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"line_id": line_id,
"column": column,
"value": json.dumps(value),
},
)
).all()
return [tuple(r) for r in rows]
async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.upd_form3_cells(CAST(:report_id AS INT), CAST(:changes AS JSONB))
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"changes": json.dumps(changes),
},
)
).all()
return [tuple(r) for r in rows]
async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.add_form3_line(CAST(:report_id AS INT), CAST(:expense_item_id AS INT))
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"expense_item_id": expense_item_id,
},
)
).all()
return [tuple(r) for r in rows]
async def del_form3_line(self, line_id: int) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.del_form3_line(CAST(:line_id AS INT))
"""
)
rows = (await self.db.execute(query, {"line_id": line_id})).all()
return [tuple(r) for r in rows]
async def del_project(self, project_id: int):
query = text(
"""
SELECT v3.del_project(CAST(:project_id AS INT))
"""
)
result = (await self.db.execute(query, {"project_id": project_id})).scalar_one()
return result
async def upd_project(self, project_id: int, column: str, value):
query = text(
"""
SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB))
"""
)
result = (
await self.db.execute(
query,
{
"project_id": project_id,
"column": column,
"value": json.dumps(value),
},
)
).scalar_one()
return result
async def add_project(
self,
name: str,
year: int,
org_unit_id: int,
level: str = "project",
parent_id: int | None = None,
project_type: str | None = None,
vsp_format: str | None = None,
placement_type: str | None = None,
object_address: str | None = None,
staff_count: int | None = None,
total_area: float | None = None,
) -> tuple[int, int, int]:
query = text(
"""
SELECT project_id, limit_report_id, current_expenses_report_id
FROM v3.add_project(
CAST(:name AS VARCHAR),
CAST(:year AS INT),
CAST(:org_unit_id AS INT),
CAST(:level AS VARCHAR),
CAST(:parent_id AS INT),
CAST(:project_type AS VARCHAR),
CAST(:vsp_format AS VARCHAR),
CAST(:placement_type AS VARCHAR),
CAST(:object_address AS VARCHAR),
CAST(:staff_count AS INT),
CAST(:total_area AS NUMERIC)
)
"""
)
row = (
await self.db.execute(
query,
{
"name": name,
"year": year,
"org_unit_id": org_unit_id,
"level": level,
"parent_id": parent_id,
"project_type": project_type,
"vsp_format": vsp_format,
"placement_type": placement_type,
"object_address": object_address,
"staff_count": staff_count,
"total_area": total_area,
},
)
).first()
return int(row[0]), int(row[1]), int(row[2])

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

@ -0,0 +1,298 @@
import enum
import itertools
import json
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormType
class DirectionEnum(str, enum.Enum):
SUPPORT = "Support"
DEVELOPMENT = "Development"
ACCEPTABLE_SHEETS = set(itertools.chain(*FormType.SHEETS_BY_FORM_TYPE.values()))
ACCEPTABLE_SECTIONS = set(itertools.chain(*FormType.SECTIONS_BY_FORM_TYPE.values()))
class SheetValidationError(Exception):
def __init__(self, message, *args):
super().__init__(*args)
self.message = message
class SheetRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
if direction:
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
else:
func_query = "v3.v_form_view(:form_id, :sheet, :sections)"
return (
await self.db.execute(
text(
f"""
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
}
)
).all()
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_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
if user_id:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :user_id, :direction, :sections)"
else:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :direction, :sections)"
return [
tuple(r) for r in (
await self.db.execute(
text(
f"""
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"line_id": line_id,
"column": column,
"value": json.dumps(value),
"user_id": user_id,
}
)
).all()
]
async def update_cells(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None,
changes: list[dict],
user_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
changes = json.dumps(changes)
if user_id:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :user_id, :direction, :sections)"
else:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :direction, :sections)"
return [
tuple(r) for r in (
await self.db.execute(
text(
f"""
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"changes": changes,
"user_id": user_id,
}
)
).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,
sheet: str,
expense_item_id: int | None = None,
item_id: str | None = None,
section_code: str | None = None,
direction: str | None = None,
name: str | None = None,
internal_order: str | None = None,
vsp_id: int | None = None,
project_id: int | None = None,
justification: str | None = None,
contract_number: str | None = None,
contract_end_date: str | None = None,
user_id: int | None = None,
) -> list[tuple]:
return [
tuple(r) for r in (
await self.db.execute(
text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.add_budget_line(
:p_form_id,
:p_expense_item_id,
:p_sheet,
:p_item_id,
:p_section_code,
:p_direction,
:p_name,
:p_internal_order,
:p_vsp_id,
:p_project_id,
:p_justification,
:p_contract_number,
:p_contract_end_date
)
"""
),
{
"p_form_id": form_id,
"p_expense_item_id": expense_item_id,
"p_sheet": sheet,
"p_item_id": item_id,
"p_section_code": section_code,
"p_direction": direction,
"p_name": name,
"p_internal_order": internal_order,
"p_vsp_id": vsp_id,
"p_project_id": project_id,
"p_justification": justification,
"p_contract_number": contract_number,
"p_contract_end_date": contract_end_date,
}
)
).all()
]
async def delete_line(
self,
sheet: str,
row_id: int,
direction: str | None = None,
) -> list[tuple]:
return [
tuple(r) for r in (
await self.db.execute(
text(
"SELECT row_type, depth, sort_order, data "
"FROM v3.del_budget_line(:p_row_id, :p_direction, :p_sheet)"
),
{
"p_row_id": row_id,
"p_direction": direction,
"p_sheet": sheet,
}
)
).all()
]

View File

@ -1,96 +1,120 @@
from typing import Optional
from sqlalchemy import delete, select, update
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from src.db.models.org_unit import OrgUnit
from src.db.models.role import Role
from src.db.models.user_org import UserOrg
from src.db.models.app_user import AppUser
from src.core.security import get_password_hash, verify_password
from src.domain.models import Roles, SSP, UserSSPLink, Users
class UserRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, user_id: int) -> Optional[Users]:
async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
query = select(AppUser).where(AppUser.id == user_id)
if load_orgs:
query = query.options(
selectinload(AppUser.org_units),
)
return (
(
await self.db.execute(
select(Users).where(Users.id == user_id).limit(1)
query.limit(1)
)
)
.scalars()
.first()
)
async def get_by_email(self, email: str) -> Optional[Users]:
async def get_by_email(self, email: str) -> Optional[AppUser]:
return (
(
await self.db.execute(
select(Users).where(Users.email == email).limit(1)
select(AppUser).where(AppUser.email == email).limit(1)
)
)
.scalars()
.first()
)
async def get_by_username(self, username: str) -> Optional[Users]:
async def get_by_username(self, username: str) -> Optional[AppUser]:
return (
(
await self.db.execute(
select(Users).where(Users.username == username).limit(1)
select(AppUser).where(AppUser.username == username).limit(1)
)
)
.scalars()
.first()
)
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
query = (
select(Users)
.where(Users.is_active.is_(True))
.offset(skip)
.limit(limit)
.order_by(Users.id)
)
return (await self.db.execute(query)).scalars().all()
async def set_app_user_id(self, user_id: int) -> None:
if isinstance(user_id, int):
await self.db.execute(
text(
f"SET LOCAL app.user_id = {user_id}"
),
)
async def create(self, user_data: dict) -> Users:
hashed_password = (
get_password_hash(user_data["password"]) if user_data.get("password") else None
)
db_user = Users(
email=user_data["email"],
username=user_data["username"],
hashed_password=hashed_password,
full_name=user_data.get("full_name"),
role_id=user_data.get("role_id"),
)
self.db.add(db_user)
await self.db.commit()
await self.db.refresh(db_user)
return db_user
async def get_list(
self,
skip: int = 0,
limit: int = 100,
with_count: bool = False,
load_orgs: bool = False,
) -> list[AppUser] | tuple[int, list[AppUser]]:
where = [AppUser.is_active.is_(True)]
if with_count:
query = select(func.count().over().label("total_count"), AppUser)
else:
query = select(AppUser)
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
user = await self.get(user_id)
if not user:
return None
query = query.where(*where)
if load_orgs:
query = query.options(
joinedload(AppUser.org_units)
)
query = query.order_by(AppUser.id)
for field, value in user_data.items():
if field == "password" and value:
value = get_password_hash(value)
setattr(user, field, value)
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
await self.db.commit()
await self.db.refresh(user)
return user
if with_count:
result = (await self.db.execute(query)).unique().all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(AppUser.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().unique().all()
async def logical_delete(self, user_id: int) -> bool:
query = update(Users).where(Users.id == user_id).values(is_active=False)
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
result = await self.db.execute(query)
await self.db.commit()
return result.rowcount > 0
async def authenticate(self, username: str, password: str) -> Optional[Users]:
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
user = await self.get_by_username(username)
if not user:
return None
@ -98,52 +122,122 @@ class UserRepository:
return None
return user
async def authenticate_via_email(self, email: str) -> Optional[Users]:
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
return await self.get_by_email(email)
async def get_roles(self) -> list[Roles]:
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
async def get_roles(self) -> list[Role]:
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
async def get_many_ssp(self, user_id: int) -> list[SSP]:
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
UserSSPLink.user_id == user_id
async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
query = select(OrgUnit).join(UserOrg, UserOrg.org_unit_id == OrgUnit.id).where(
UserOrg.user_id == user_id
)
return (await self.db.execute(query)).scalars().all()
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
query = select(UserOrg.org_unit_id).where(UserOrg.user_id == user_id)
return (await self.db.execute(query)).scalars().all()
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids:
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
self.db.add(link)
await self.db.commit()
return True
except Exception:
await self.db.rollback()
return False
for ssp_id in ssp_ids:
await self.db.execute(
text(
"SELECT v3.grant_user_org_access(:user_id, :org_unit_id)"
),
{"user_id": user_id, "org_unit_id": ssp_id},
)
await self.db.commit()
return True
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids:
query = delete(UserSSPLink).where(
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
)
await self.db.execute(query)
await self.db.commit()
return True
except Exception:
await self.db.rollback()
return False
for ssp_id in ssp_ids:
await self.db.execute(
text(
"SELECT v3.revoke_user_org_access(:user_id, :org_unit_id)"
),
{"user_id": user_id, "org_unit_id": ssp_id},
)
await self.db.commit()
return True
async def clear_many_ssp(self, user_id: int) -> bool:
try:
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
await self.db.execute(query)
await self.db.commit()
return True
except Exception:
await self.db.rollback()
return False
async def clear_many_ssp(self, user_id: int) -> None:
org_unit_ids = await self.get_many_ssp_ids(user_id)
await self.db.execute(
text(
"SELECT v3.revoke_many_user_org_access(:user_id, :org_unit_ids)"
),
{"user_id": user_id, "org_unit_ids": org_unit_ids},
)
await self.db.flush()
async def create(self, user_data: dict) -> AppUser:
hashed_password = (
get_password_hash(user_data["password"]) if user_data.get("password") else None
)
user_id = (
await self.db.execute(
text(
"""
SELECT (v3.add_user(
:email,
:username,
:hashed_password,
:full_name,
:role_id
)).id
"""
),
{
"email": user_data["email"],
"username": user_data["username"],
"hashed_password": hashed_password,
"full_name": user_data.get("full_name"),
"role_id": user_data.get("role_id"),
},
)
).scalar_one()
await self.db.flush()
return await self.get(user_id)
async def update(
self,
user_id: int,
user_data: dict,
load_orgs: bool = False,
) -> Optional[AppUser]:
user = await self.get(user_id)
if not user:
return None
hashed_password = None
if "password" in user_data and user_data["password"]:
hashed_password = get_password_hash(user_data["password"])
await self.db.execute(
text(
"""
SELECT (v3.upd_user(
:user_id,
:email,
:username,
:hashed_password,
:full_name,
:role_id,
:is_active
)).id
"""
),
{
"user_id": user_id,
"email": user_data.get("email"),
"username": user_data.get("username"),
"hashed_password": hashed_password,
"full_name": user_data.get("full_name"),
"role_id": user_data.get("role_id"),
"is_active": user_data.get("is_active"),
},
)
await self.db.flush()
self.db.expire(user)
result = await self.get(user_id, load_orgs=load_orgs)
return result

View File

@ -0,0 +1,246 @@
from datetime import date
from typing import Optional
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from src.db.models import Vsp
from src.domain.schemas import VSPCreate
class VSPRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
vsp_id: int,
is_active: bool | None = None,
load_org_unit: bool = False,
with_deleted: bool = False,
) -> Optional[Vsp]:
queries = [Vsp.id == vsp_id, Vsp.is_deleted.is_(with_deleted)]
if is_active is not None:
queries.append(Vsp.is_active == is_active)
if load_org_unit:
return (
(await self.db.execute(
select(Vsp).where(*queries).options(joinedload(Vsp.org_unit)).limit(1))
).scalars().first()
)
else:
return (
(await self.db.execute(select(Vsp).where(*queries).limit(1))).scalars().first()
)
async def get_list(
self,
registration_number: str | None = None,
address: str | None = None,
ssp_ids: list[int] | None = None,
open_date_start: date | None = None,
open_date_end: date | 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,
with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]:
if with_count:
query = select(func.count().over().label("total_count"), Vsp)
else:
query = select(Vsp)
queries = [Vsp.is_deleted.is_(False)]
filters = [
("registration_number", registration_number),
("address", address),
("placement_type", placement_type),
("open_date_start", open_date_start),
("open_date_end", open_date_end),
("staff_count_min", staff_count_min),
("staff_count_max", staff_count_max),
("is_active", is_active),
]
for filter_name, filter_value in filters:
# print(filter_name, filter_value)
match filter_name, filter_value:
case ("registration_number", str(value)) if value:
queries.append(Vsp.reg_number.ilike(f"%{value}%"))
case ("address", str(value)) if value:
queries.append(Vsp.address.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 ("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 _:
continue
if close_date_is_null is True:
queries.append(Vsp.closed_at.is_(None))
elif close_date_is_null is False:
queries.append(Vsp.closed_at.is_not(None))
if ssp_ids is not None:
if not ssp_ids:
if with_count:
return 0, []
return []
queries.append(Vsp.branch_id.in_(ssp_ids))
query = query.where(*queries)
if load_org_unit:
query = query.options(joinedload(Vsp.org_unit))
query = query.order_by(Vsp.id)
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(Vsp.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()
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
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("""
SELECT v3.add_vsp(
: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_rent_contract_num, :p_rent_end_date
)
"""),
{
"p_branch_id": data.get("branch_id"),
"p_reg_number": data.get("reg_number"),
"p_address": data.get("address"),
"p_format": data.get("format"),
"p_opened_at": data.get("opened_at"),
"p_placement_type": data.get("placement_type"),
"p_staff_count": data.get("staff_count"),
"p_total_area": data.get("total_area"),
"p_closed_at": data.get("closed_at"),
"p_is_active": data.get("is_active", True),
"p_is_deleted": data.get("is_deleted", False),
"p_system_code": data.get("system_code"),
"p_created_by": created_by,
"p_vsp_type": data.get("vsp_type"),
"p_notes": data.get("notes"),
"p_rent_contract_num": data.get("rent_contract_num"),
"p_rent_end_date": data.get("rent_end_date"),
}
)
).scalar()
await self.db.flush()
if load_org_unit:
return (
await self.db.execute(
select(Vsp).where(Vsp.id == vsp_id).options(joinedload(Vsp.org_unit)).limit(1)
)
).scalars().first()
else:
return (
await self.db.execute(
select(Vsp).where(Vsp.id == vsp_id).limit(1)
)
).scalars().first()
async def update(self, vsp: Vsp, data: dict, updated_by: int, load_org_unit: bool = False) -> Vsp:
FIELD_TO_PARAM = {
"system_code": "p_system_code",
"vsp_type": "p_vsp_type",
"reg_number": "p_reg_number",
"address": "p_address",
"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",
"total_area": "p_total_area",
"rent_contract_num": "p_rent_contract_num",
"rent_end_date": "p_rent_end_date",
"branch_id": "p_branch_id",
"is_active": "p_is_active",
"is_deleted": "p_is_deleted",
}
params = {"p_vsp_id": vsp.id, "p_updated_by": updated_by}
for field, param in FIELD_TO_PARAM.items():
params[param] = data.get(field)
await self.db.execute(
text("""
SELECT v3.upd_vsp(
:p_vsp_id, :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_updated_by, :p_vsp_type, :p_notes,
:p_rent_contract_num, :p_rent_end_date
)
"""),
params,
)
await self.db.flush()
await self.db.refresh(vsp)
if load_org_unit:
return (
await self.db.execute(
select(Vsp).where(Vsp.id == vsp.id).options(joinedload(Vsp.org_unit)).limit(1)
)
).scalars().first()
else:
return vsp
async def deactivate_by_close_date(self, as_of: date) -> int:
result = await self.db.execute(
update(Vsp)
.where(
Vsp.is_active.is_(True),
Vsp.closed_at.is_not(None),
Vsp.closed_at <= as_of,
)
.values(is_active=False, updated_at=func.now())
)
await self.db.commit()
return result.rowcount or 0
async def logical_delete(self, vsp_id: int, updated_by: int) -> bool:
result = (
await self.db.execute(
text("SELECT v3.del_vsp(:p_vsp_id, :p_deleted_by)"),
{"p_vsp_id": vsp_id, "p_deleted_by": updated_by},
)
).scalar()
await self.db.flush()
return result is not None

View File

@ -0,0 +1,46 @@
import enum
import typing
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
class AccessEnum(int, enum.Enum):
RESTRICT = 0
VIEW = 1
EDIT = 2
class AccessControlService:
def __init__(self, db: AsyncSession, user: AppUser):
self.db = db
self.user_repo = UserRepository(db)
async def _check_budget_form(
self,
user: AppUser,
budget_form: BudgetForm,
) -> AccessEnum:
if user.role_id == UserRoleEnum.ADMIN:
return AccessEnum.EDIT
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
if budget_form.org_unit_id in [ou.id for ou in user.org_units]:
return AccessEnum.EDIT
return AccessEnum.RESTRICT
async def check_object_access(
self,
user: AppUser,
object: typing.Any
) -> AccessEnum:
if isinstance(object, BudgetForm):
return await self._check_budget_form(user=user,budget_form=object)
return AccessEnum.EDIT

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