Листы Операц/АХР/КВ формы «Смета РФ» (FORM_2) выгружались раскладкой формы 1
(FORM1_DEPTH_COLUMNS, 180 колонок формы 1) — из 163 колонок экрана совпадали
по ключу только 83, 97 колонок выгрузки были всегда пустыми (блоки формы 1,
которых нет в данных формы 2), заголовки групп — от формы 1. Также на экране
был лишний столбец «Адрес ВСП», которого нет в исходной форме РФ, и ВСП
выгружался бы числовым id вместо номера, который виден на экране.
- FORM2_DEPTH_COLUMNS/FORM2_COLORS (export_mappings.py) — сгенерированы из
web/src/.../FORM_2/{OPER,AHR,CAP}.js (колонки идентичны у всех трёх листов,
проверено программно; различаются только цвета). 162 листа дерева —
байт-в-байт тот же порядок, что на экране, минус header.vsp_address.
Каждая группа — чисто визуальная обёртка ("key": None), каждый лист несёт
полный путь данных как "key" (например "booking.y2027.q1"), поэтому порядок
вложенности групп для Excel-заголовков не завязан на реальный путь к данным.
- Form2DepthRowNormalizer (export_normalizers.py) — в отличие от
Form1DepthRowNormalizer, не поднимает header.* на верхний уровень: FORM_2
ожидает их как header.section_code, header.vsp_id и т.д.
- _create_xlsxwriter_sheet_writers: отдельная ветка для FormTypeEnum.FORM_2
(раньше FORM_2 попадала в общий else вместе с FORM_1). Сателлиты АХР
(AHR_RENT/UTILITY/SECURITY, AURORA-1904) и STRUCTURE не трогал.
- VSPRepository.get_reg_numbers_by_ids + ExportService._resolve_form2_vsp_numbers:
один запрос резолвит все встретившиеся header.vsp_id (включая закрытые ВСП)
в registration_number перед записью в Excel, как уже показывает экран.
- web/.../FORM_2/{OPER,AHR,CAP}.js: убран столбец «Адрес ВСП» (E1) и его цвет —
по умолчанию, т.к. его нет в исходной форме РФ (см. Rulings в PR). Убрана
мёртвая ссылка на него в Stages/constants.js.
Issue: #252 (AURORA-1902, эпик AURORA-1876, Высокий приоритет)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
9.8 KiB
Python
258 lines
9.8 KiB
Python
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_reg_numbers_by_ids(self, vsp_ids: list[int]) -> dict[int, str]:
|
|
"""Номера ВСП по id, включая закрытые (is_active=False), для подписи в экспорте."""
|
|
if not vsp_ids:
|
|
return {}
|
|
rows = (
|
|
await self.db.execute(
|
|
select(Vsp.id, Vsp.reg_number).where(Vsp.id.in_(vsp_ids), Vsp.is_deleted.is_(False))
|
|
)
|
|
).all()
|
|
return {row.id: row.reg_number for row in rows}
|
|
|
|
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
|
|
|
|
|