diff --git a/apps/api/app/routers/catalog.py b/apps/api/app/routers/catalog.py index 3fd9858..61a29c5 100644 --- a/apps/api/app/routers/catalog.py +++ b/apps/api/app/routers/catalog.py @@ -5,8 +5,8 @@ from sqlalchemy import func, select from sqlalchemy.orm import selectinload from ..dependencies import Db -from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody -from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, WaterbodyOut +from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, RigComponent, Spot, TackleItem, Waterbody +from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, TackleItemRigOut, WaterbodyOut router = APIRouter() @@ -77,7 +77,9 @@ def tackle_item(item_id: UUID, db: Db) -> TackleItemOut: item = db.get(TackleItem, item_id) if item is None: raise HTTPException(status_code=404, detail="tackle item not found") - return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) + rigs = list(db.scalars(select(Rig).join(RigComponent, RigComponent.rig_id == Rig.id).where(RigComponent.tackle_item_id == item_id).options(selectinload(Rig.components)).distinct().order_by(Rig.name, Rig.id))) + related = [TackleItemRigOut(id=rig.id, name=rig.name, source_system=rig.source_system, source_checked_at=rig.source_checked_at, component_count=len(rig.components)) for rig in rigs] + return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item), "related_rigs": related}) @router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut) diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 6a8c0cd..244a1b0 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -40,6 +40,14 @@ class BaitOut(BaseModel): kind: str +class TackleItemRigOut(BaseModel): + id: UUID + name: str + source_system: str | None + source_checked_at: datetime | None + component_count: int + + class TackleItemOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID @@ -54,6 +62,7 @@ class TackleItemOut(BaseModel): source_url: str | None source_checked_at: datetime | None missing_fields: list[str] = Field(default_factory=list) + related_rigs: list[TackleItemRigOut] = Field(default_factory=list) class PaginatedTackleItemOut(BaseModel): diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 079d2da..6717d31 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -20,7 +20,7 @@ export type RigComponent = { id: string; role: string; position: number; raw_val export type Rig = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; components: RigComponent[] }; export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null; tackle_components: CatchTackleComponent[] }; export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null }; -export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] }; +export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; related_rigs: { id: string; name: string; source_system: string | null; source_checked_at: string | null; component_count: number }[] }; export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number }; export type RigSummary = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; component_count: number }; export type PaginatedRigs = { items: RigSummary[]; total: number; limit: number; offset: number }; diff --git a/apps/web/src/pages/tackle/items/[id].astro b/apps/web/src/pages/tackle/items/[id].astro index 73c64ad..ddbf765 100644 --- a/apps/web/src/pages/tackle/items/[id].astro +++ b/apps/web/src/pages/tackle/items/[id].astro @@ -42,6 +42,18 @@ const mediaCandidates = item ? (["tackle_card", "tackle_detail"] as const).map(r
Подкатегория
{item.subcategory ?? "Не указана"}
Уровень открытия
{item.unlock_level ?? "Не указан"}
Источник
{item.source_system ?? "Не указан"}
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{sourceHref && Открыть первоисточник}
+ {mediaCandidates.length > 0 &&
{mediaCandidates.map(asset =>
{mediaRoleLabel(asset.media_role ?? "")}
)}
} } + + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 61d3e64..ce6254d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -67,7 +67,7 @@ найденных изображений приманок не считается размером полного каталога. - [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом. -- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы сборок сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. +- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы сборок сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Detail карточки снасти теперь показывает подтверждённые сборки, в которых используется canonical item, с прямыми ссылками и количеством компонентов. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. - [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk. - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.