Compare commits

...

13 Commits

Author SHA1 Message Date
bac2a7a780 logs: логируем время 2026-06-26 10:00:27 +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
59f9170e9f export-optimization: не красим обычные ячейки 2026-06-25 10:29:02 +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
c5c7963b5a migrations: закомментил все что связано с раисовской авторизацией при debug=false 2026-06-24 10:50:15 +03:00
22 changed files with 392 additions and 201 deletions

View File

@ -3244,7 +3244,7 @@ BEGIN
LEFT JOIN collegial_approval ca ON ca.line_id = bl.id AND s_ca
LEFT JOIN ckk ck ON ck.line_id = bl.id AND (s_ckk OR s_need_bk)
LEFT JOIN contract_detail cd ON cd.line_id = bl.id AND (s_cd OR s_need_bk)
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND (p_direction is null OR bl.direction is null or bl.direction = p_direction)
AND (s_need_ap OR s_cs OR s_ca OR s_ckk OR s_cd OR s_need_bk)
GROUP BY bl.expense_item_id
),
@ -3257,7 +3257,7 @@ BEGIN
SUM(blq.transfer_to_q2) AS tq2, SUM(blq.transfer_to_q3) AS tq3, SUM(blq.transfer_to_q4) AS tq4
FROM budget_line bl JOIN expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN budget_line_quarter blq ON blq.line_id = bl.id AND blq.quarter = 1
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_q1
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_q1 AND (p_direction is null OR bl.direction is null or bl.direction = p_direction)
GROUP BY bl.expense_item_id
),
aq2 AS (
@ -3270,7 +3270,7 @@ BEGIN
SUM(blq.transfer_to_q3) AS tq3, SUM(blq.transfer_to_q4) AS tq4
FROM budget_line bl JOIN expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN budget_line_quarter blq ON blq.line_id = bl.id AND blq.quarter = 2
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_q2
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_q2 AND (p_direction is null OR bl.direction is null or bl.direction = p_direction)
GROUP BY bl.expense_item_id
),
aq3 AS (
@ -3283,7 +3283,7 @@ BEGIN
SUM(blq.transfer_to_q4) AS tq4
FROM budget_line bl JOIN expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN budget_line_quarter blq ON blq.line_id = bl.id AND blq.quarter = 3
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_q3
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_q3 AND (p_direction is null OR bl.direction is null or bl.direction = p_direction)
GROUP BY bl.expense_item_id
),
aq4 AS (
@ -3296,7 +3296,7 @@ BEGIN
SUM(blq.actual_spod) AS spod
FROM budget_line bl JOIN expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN budget_line_quarter blq ON blq.line_id = bl.id AND blq.quarter = 4
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND (s_q4 OR s_tot)
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND (s_q4 OR s_tot) AND (p_direction is null OR bl.direction is null or bl.direction = p_direction)
GROUP BY bl.expense_item_id
),
atot AS (
@ -3308,7 +3308,7 @@ BEGIN
SUM(blq.payment_amount) AS pay
FROM budget_line bl JOIN expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN budget_line_quarter blq ON blq.line_id = bl.id
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_tot
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet AND s_tot AND (p_direction is null OR bl.direction is null or bl.direction = p_direction)
GROUP BY bl.expense_item_id
),
-- ═══ Pre-computed per-node aggregates (one pass over tw × agg) ═════════
@ -4427,7 +4427,7 @@ BEGIN
LEFT JOIN v3.sequestration sg ON sg.line_id = bl.id AND sg.actor='SSP_GO' AND s_need_ap
LEFT JOIN v3.reserve r ON r.line_id = bl.id AND s_need_ap
LEFT JOIN v3.ckk ck ON ck.line_id = bl.id AND s_book
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet and (p_direction is null OR bl.direction is null or bl.direction = p_direction)
AND (s_need_ap OR s_book)
GROUP BY bl.expense_item_id
),
@ -4453,7 +4453,7 @@ BEGIN
FROM v3.budget_line bl
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
JOIN v3.budget_line_quarter q ON q.line_id = bl.id
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet
WHERE bl.budget_form_id = p_form_id AND ei.sheet = p_sheet and (p_direction is null OR bl.direction is null or bl.direction = p_direction)
GROUP BY bl.expense_item_id, q.quarter
),
-- Tree-rollup

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

@ -12,62 +12,62 @@ from src.repository.user_repository import UserRepository
security = HTTPBearer()
if settings.DEBUG:
# if settings.DEBUG:
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"},
)
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:
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
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(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AppUser:
return await get_user_by_token(token=credentials.credentials, db=db)
else:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# 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_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: AppUser = Depends(get_current_user)) -> AppUser:

View File

@ -1,3 +1,4 @@
import logging
import time
from typing import Optional
@ -20,6 +21,9 @@ SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
logger = logging.getLogger()
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
return [
SheetResponse(
@ -153,6 +157,20 @@ async def get_sheet(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_sheet",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -206,6 +224,22 @@ async def update_cell(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "update_form_cell",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
"line_id": cell_body.line_id,
"column": cell_body.column,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -261,6 +295,21 @@ async def update_cells(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "update_form_cells",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
"count": len(cells_body.changes)
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -310,6 +359,21 @@ async def add_line(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "add_form_line",
"form_id": form_id,
"sheet": sheet,
"direction": body.direction,
"user": current_user.id,
"expense_item_id": body.expense_item_id
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -351,6 +415,21 @@ async def delete_line(
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "del_form_line",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"user": current_user.id,
"line_id": row_id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
@ -384,6 +463,18 @@ async def add_form(
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "add_form",
"form_type_code": form.form_type_code,
"user": current_user.id,
"count_orgs": len(form.org_unit_ids),
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],

View File

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

View File

@ -59,12 +59,13 @@ async def get_vsp(
@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, with_count=True)
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 для выпадающего списка",

View File

@ -4,6 +4,8 @@ from dataclasses import asdict
import dataclasses
import enum
from json import dumps, loads
import logging
import time
from typing import Any, Optional
#
@ -26,6 +28,9 @@ from src.db.session import SessionLocal
from src.services.user_service import UserService
logger = logging.getLogger()
@asynccontextmanager
async def get_db_session(user_id: int | None = None):
"""Контекстный менеджер для получения сессии базы данных."""
@ -770,12 +775,25 @@ async def process_websocket(
async with get_db_session(user_id=user_id) as db:
processor = processor_cls(db)
t0 = time.perf_counter()
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)
logger.info(
f"db_time: {extra}",
extra=extra,
)
if data.get("event") == "row_deleted":
manager.release_locks_for_row(

View File

@ -1,4 +1,5 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
@ -20,15 +21,19 @@ 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
@ -99,13 +104,13 @@ 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
@ -180,41 +185,41 @@ async def readyz(response: Response):
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,
),
],
)
# 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,

View File

@ -181,7 +181,7 @@ class DepthSheetWriter:
worksheet.column_dimensions[col_letter].width = adjusted_width
def _set_color_to_row(self, ws: Worksheet, item_type: str) -> None:
if not self.palette:
if not self.palette or item_type in ("SUB_ITEM", "INPUT"):
return
row = ws.max_row

View File

@ -7,6 +7,7 @@ from openpyxl import Workbook
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.budget_form_repository import BudgetFormRepository
from src.core.errors import AccessDeniedException, ValidationException
from src.db.models import AppUser, UserRoleEnum, Vsp
from src.domain.schemas import VSPCreate, VSPUpdate
@ -29,6 +30,7 @@ class VSPService:
def __init__(self, db: AsyncSession):
self.db = db
self.vsp_repo = VSPRepository(db)
self.form_repo = BudgetFormRepository(db)
self.user_repo = UserRepository(db)
async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]:
@ -94,19 +96,27 @@ class VSPService:
self,
user: AppUser,
ssp_id: int | None = None,
form_id: int | None = None,
with_count: bool = False,
) -> list[Vsp]:
ssp_ids = await self._get_scoped_ssp_ids(user=user)
checking_ssps = set()
if ssp_id is not None:
checking_ssps.add(ssp_id)
if form_id is not None:
form = await self.form_repo.get(budget_form_id=form_id)
if form is not None:
checking_ssps.add(form.org_unit_id)
if checking_ssps:
if ssp_ids is None:
ssp_ids = [ssp_id]
elif ssp_id not in set(ssp_ids):
ssp_ids = list(checking_ssps)
elif not checking_ssps & set(ssp_ids):
if with_count:
return 0, []
return []
else:
ssp_ids = [ssp_id]
ssp_ids = list(checking_ssps & set(ssp_ids))
return await self.vsp_repo.get_list(
ssp_ids=ssp_ids,

View File

@ -37,14 +37,25 @@ def test_vsp_dropdown_smoke(client, admin_tokens, auth_headers):
assert isinstance(payload["result"], list)
def test_vsp_dropdown_with_ssp_filter(client, admin_tokens, auth_headers):
@pytest.mark.parametrize(
"params, count",
[
([("ssp_id", 2)], 1),
([("form_id", 1)], 0),
([("form_id", 2)], 1),
([("ssp_id", 2), ("form_id", 1)], 1),
]
)
def test_vsp_dropdown_with_filter(client, admin_tokens, auth_headers, params, count):
filters = "&".join([f"{el[0]}={el[1]}" for el in params])
response = client.get(
"/api/v1/dict/info/dropdown?ssp_id=1",
f"/api/v1/dict/info/dropdown?{filters}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload["result"], list)
assert len(payload["result"]) == count
def test_vsp_get_by_id(client, admin_tokens, auth_headers):

View File

@ -15,8 +15,9 @@ INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
SELECT setval('v3.user_org_id_seq', 3);
INSERT INTO v3.budget_form (id,form_type_code,"year",created_by,created_at,updated_by,updated_at,org_unit_id) VALUES
(1,'FORM_1',2027,NULL,'2026-05-06 16:57:38.066371',NULL,'2026-06-11 14:35:53.783017',1);
SELECT setval('v3.budget_form_id_seq', 1);
(1,'FORM_1',2027,NULL,'2026-05-06 16:57:38.066371',NULL,'2026-06-11 14:35:53.783017',1),
(4,'FORM_2',2026,NULL,'2026-05-08 09:08:12.230445',NULL,'2026-05-08 09:08:12.230445',2);
SELECT setval('v3.budget_form_id_seq', 4);
INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_order,vsp_id,project_id,justification,created_by,created_at,updated_by,updated_at,direction) VALUES
(1,1,88,'1234',NULL,NULL,NULL,NULL,NULL,'2026-05-06 16:58:05.793083',NULL,'2026-06-22 17:41:54.255313','Support');
@ -26,5 +27,6 @@ INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,op
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03');
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,location_form,numbers,rent_contract_num,rent_end_date) VALUES
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','встроенное помещение',NULL,'',NULL);
SELECT setval('v3.vsp_id_seq', 1);
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','встроенное помещение',NULL,'',NULL),
(2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','аренда',155,'12-45','2026-10-02');
SELECT setval('v3.vsp_id_seq', 2);

View File

@ -143,7 +143,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
}
},
columnVirtualizerOptions: ({ table }) => ({
overscan: 1,
overscan: 10,
measureElement: (el) => {
if (!el) return 150;
const index = Number(el?.getAttribute?.('data-index'));
@ -152,7 +152,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const colId = table.getState().columnPinning.left[index];
const column = table.getColumn(colId);
return column.columnDef.size;
}
const allCols = [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns()]
return allCols[index]?.getSize() ?? 150;

View File

@ -168,7 +168,7 @@ const SettingPanel = ({
<ExportDefaultButton
onClick={handleExport}
disabled={!formId || !sheetName || isExporting}
text={isExporting ? 'Экспорт...' : 'Экспорт в Excel'}
text={isExporting ? 'Экспорт...' : 'Экспорт'}
/>
</GroupByObject>
</Stack>

View File

@ -38,13 +38,12 @@ export const AddStagesModal = ({
sheetOptions = [], // [{ value: 'sheet1', label: 'Лист 1' }]
formType = '',
defaultSheet = '',
defaultRole,
}) => {
const [startDate, setStartDate] = useState(null);
const [endDate, setEndDate] = useState(null);
const [stageName, setStageName] = useState('');
const [selectedSheet, setSelectedSheet] = useState(defaultSheet);
const [selectedRole, setSelectedRole] = useState(defaultRole);
const [selectedRole, setSelectedRole] = useState();
const [columnsConfig, setColumnsConfig] = useState({ columns: [], colors: {} });
const [selectedColumns, setSelectedColumns] = useState([]);
@ -89,10 +88,10 @@ export const AddStagesModal = ({
setEndDate(null);
setStageName('');
setSelectedSheet(defaultSheet);
setSelectedRole(defaultRole);
setSelectedRole();
setSelectedColumns([]);
}
}, [stage, isOpen, defaultSheet, defaultRole]);
}, [stage, isOpen, defaultSheet]);
const handleSave = () => {
const opensAt = formatDateForApi(startDate);
@ -129,7 +128,7 @@ export const AddStagesModal = ({
setEndDate(null);
setStageName('');
setSelectedSheet(defaultSheet);
setSelectedRole(defaultRole);
setSelectedRole();
setSelectedColumns([]);
onClose();
};
@ -238,6 +237,7 @@ export const AddStagesModal = ({
placeholderSelect="Выбрано столбцов: "
selectedIds={selectedColumns}
onSelectNode={handleColumnsSelect}
forStage={true}
/>
</FormControl>
</Stack>
@ -247,7 +247,7 @@ export const AddStagesModal = ({
<FormControl fullWidth>
<FormLabel>Роль</FormLabel>
<Select
value={selectedRole}
value={selectedRole ?? ''}
onChange={(e) => setSelectedRole(e.target.value)}
displayEmpty
size="small"

View File

@ -76,41 +76,59 @@ export const SettingModal = ({
setIsAddEditModalOpen(true);
};
const transformToSheetOptions = (data) => {
const sheetMap = new Map();
// TO DO пока не добавили direction для этапов
data.forEach(item => {
const key = item.direction ? `${item.sheet}_${item.direction}` : item.sheet;
// const sheetMap = new Map();
if (!sheetMap.has(key)) {
sheetMap.set(key, {
sheet: item.sheet,
direction: item.direction,
names: []
});
}
// data.forEach(item => {
// const key = item.direction ? `${item.sheet}_${item.direction}` : item.sheet;
const entry = sheetMap.get(key);
if (!entry.names.includes(item.name)) {
entry.names.push(item.name);
}
});
// if (!sheetMap.has(key)) {
// sheetMap.set(key, {
// sheet: item.sheet,
// direction: item.direction,
// names: []
// });
// }
// const entry = sheetMap.get(key);
// if (!entry.names.includes(item.name)) {
// entry.names.push(item.name);
// }
// });
// const sheetOptions = [];
// sheetMap.forEach((value) => {
// let label = value.names.join(' / ');
// if (value.direction) {
// label += ` (${value.direction})`;
// }
// sheetOptions.push({
// value: value.direction ? `${value.sheet}_${value.direction}` : value.sheet,
// label: label,
// sheet: value.sheet,
// direction: value.direction
// });
// });
// return sheetOptions;
const sheetOptions = [];
sheetMap.forEach((value) => {
let label = value.names.join(' / ');
if (value.direction) {
label += ` (${value.direction})`;
data.forEach((value) => {
if (sheetOptions.find(s => s.sheet == value.sheet)) {
return;
}
sheetOptions.push({
value: value.direction ? `${value.sheet}_${value.direction}` : value.sheet,
label: label,
value: value.sheet,
label: value.name,
sheet: value.sheet,
direction: value.direction
});
});
direction: value.direction,
})
})
return sheetOptions;
};

View File

@ -34,7 +34,7 @@ export const ExportWithTextButton = (props) => {
<Stack sx={{ flexDirection: 'row', alignItems: 'center', gap: '.375rem' }}>
<ExportSvg size="1rem" color="#45881c" strokeWidth={'.33px'} />
<span style={{ fontSize: '.88rem' }}>
{props?.text || 'Экспорт в Excel'}
{props?.text || 'Экспорт'}
</span>
</Stack>
</PrimaryOutlinedButton>
@ -57,7 +57,7 @@ export const ExportDefaultButton = (props) => {
<DefaultOutlinedButton {...props}>
<Stack sx={{ flexDirection: 'row', alignItems: 'center', gap: '.375rem' }}>
<ExportSvg color="#4a5565" strokeWidth={'.33'} size="1rem" />
<span>{props?.text || 'Экспорт в Excel'}</span>
<span>{props?.text || 'Экспорт'}</span>
</Stack>
</DefaultOutlinedButton>
);

View File

@ -21,6 +21,7 @@ const CollapsibleTree = ({
placeholderSelect = 'Скрыто столбцов: ',
selectedIds = [],
onSelectNode,
forStage = false
}) => {
const [expandedItems, setExpandedItems] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
@ -144,7 +145,7 @@ const CollapsibleTree = ({
setAnchorEl(null);
};
const selectedCount = selectedNodes.length - 1; //1 колонка всегда скрыта
const selectedCount = forStage ? selectedNodes.length : selectedNodes.length - 1; //1 колонка всегда скрыта
const isAllSelected = () => {
if (!columnTree) return false;

View File

@ -1,3 +1,4 @@
import { DIRECTION_TRANSLATE, FORM_TYPE_OPTIONS } from '../../../constants';
import {
Section,
SectionStartPart,
@ -22,7 +23,7 @@ const TableCard = ({ table, onNavigate }) => {
</div>
<SectionTitle>
<h2>{table.name}</h2>
<h2>{table?.direction}</h2>
<h2>{DIRECTION_TRANSLATE[table?.direction]}</h2>
{/* <UsersList>
<div className="img-user"></div>
</UsersList> */}

View File

@ -37,6 +37,7 @@ export const EDIT_ACCESS_RUSSIAN = {
export const FORM_TYPE_OPTIONS = ['FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'];
export const FROM_TYPE_TRANSLATE = { 'FORM_1': 'Смета ССП', 'FORM_2': 'Смета РФ', 'FORM_3': 'Программа развитие ПРРС', 'FORM_4': 'Проектная деятельность' };
export const DIRECTION_TRANSLATE = { 'Support': 'Поддержка', 'Development': 'Развитие' };
export const ORG_UNIT_TYPE_OPTIONS = [
{ value: 'ssp', label: 'ССП' },

View File

@ -22,7 +22,7 @@ import { ModalCreateProject } from './ModalCreateProject';
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
import { exportSingleForm } from '../../utils/exportForm';
import TablesList from '../../components/common/TableList/TableList';
import { FROM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants';
import { DIRECTION_TRANSLATE, FROM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants';
const TaskInfo = ({ task }) => {
const portalContent = (
@ -61,9 +61,10 @@ export default function TaskPage() {
const mappedTables = data.result.all_sheets.map(sheet => ({
name: SHEET_NAME[sheet.sheet_name] || sheet.sheet_name,
sheet: sheet.sheet_name,
direction: sheet.direction
direction: sheet.direction,
}));
setTables(mappedTables);
//пока скрыт
setTables(mappedTables.filter(t => t.sheet !== "OTCH9F",));
} catch (error) {
toast.error(
error?.response?.data?.detail || 'Ошибка получения данных задачи',

View File

@ -26,6 +26,7 @@ import {
FORM_TYPE_OPTIONS,
ORG_UNIT_TYPE_OPTIONS,
ROLES_NAME_ID,
FROM_TYPE_TRANSLATE
} from '../../constants';
import { useAuth } from '../../app/context/AuthProvider';
import { SspApi } from '../../api/ssp';
@ -129,8 +130,8 @@ export default function TasksPage() {
} catch (error) {
toast.error(
error.response?.data?.detail ||
error.response?.data?.message ||
'Ошибка при создании задачи',
error.response?.data?.message ||
'Ошибка при создании задачи',
);
} finally {
setLoadingUpdate(false);
@ -413,7 +414,7 @@ export default function TasksPage() {
onChange={(e) => handleFormChange('formTypeCode', e.target.value)}
renderValue={(selected) =>
selected ? (
selected
FROM_TYPE_TRANSLATE[selected]
) : (
<span style={{ color: 'rgba(0,0,0,0.6)' }}>Не выбрана</span>
)
@ -423,7 +424,7 @@ export default function TasksPage() {
>
{FORM_TYPE_OPTIONS.map((formType) => (
<MenuItem key={formType} value={formType}>
{formType}
{FROM_TYPE_TRANSLATE[formType]}
</MenuItem>
))}
</Select>