feat: pcat v2 — full catalog browsing with cars, groups, schema viewer
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Expand pcat API with getCars, getCarGroups, getSchemaImages, getSchemaDetail
endpoints. Add frontend routes for the complete flow:
catalog → model → car → groups → schema+parts with hotspots.
Reuses existing SchemaViewer component for interactive part diagrams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 20:03:48 +00:00
parent c61ad8c175
commit ca4437b245
8 changed files with 716 additions and 120 deletions

View File

@@ -20,6 +20,32 @@ export class PcatCatalogController {
@Param("catalogId") catalogId: string,
@Query("parentId") parentId?: string,
) {
return this.pcatCatalogService.getGroups(catalogId, parentId);
return this.pcatCatalogService.getCarGroups("", parentId);
}
@Get("catalogs/:catalogId/models/:modelId/cars")
getCars(@Param("modelId") modelId: string) {
return this.pcatCatalogService.getCars(modelId);
}
@Get("cars/:carId/groups")
getCarGroups(
@Param("carId") carId: string,
@Query("parentId") parentId?: string,
) {
return this.pcatCatalogService.getCarGroups(carId, parentId);
}
@Get("cars/:carId/groups/:groupId/schemas")
getSchemaImages(
@Param("carId") carId: string,
@Param("groupId") groupId: string,
) {
return this.pcatCatalogService.getSchemaImages(carId, groupId);
}
@Get("schemas/:schemaImageId")
getSchemaDetail(@Param("schemaImageId") schemaImageId: string) {
return this.pcatCatalogService.getSchemaDetail(schemaImageId);
}
}

View File

@@ -6,7 +6,10 @@ import { RedisService } from "../redis/redis.service";
export interface PcatCatalogDto {
id: string;
name: string;
brand: string | null;
imgUrl: string | null;
modelsCount: number;
carsCount: number;
}
export interface PcatModelDto {
@@ -14,6 +17,25 @@ export interface PcatModelDto {
catalogId: string;
name: string;
imgUrl: string | null;
yearFrom: number | null;
yearTo: number | null;
carsCount: number;
}
export interface PcatCarDto {
id: string;
modelId: string;
name: string;
yearFrom: number | null;
yearTo: number | null;
engine: string | null;
transmission: string | null;
bodyType: string | null;
fuelType: string | null;
driveType: string | null;
steering: string | null;
schemasCount: number;
partsCount: number;
}
export interface PcatGroupDto {
@@ -26,10 +48,27 @@ export interface PcatGroupDto {
hasParts: boolean;
}
export interface PcatSchemaImageDto {
id: string;
name: string | null;
imgUrl: string | null;
partsCount: number;
}
export interface PcatSchemaDetailDto {
schemaImage: PcatSchemaImageDto;
parts: { id: string; name: string; oemCode: string; quantity: number; position: string | null; hotspotIndex: number | null }[];
schemaPics: { id: string; url: string; width: number; height: number; label: string }[];
hotspots: { id: string; key: string; group: number; shape: "rect"; coordinates: number[]; label: string }[];
}
const CACHE_TTL = {
catalogs: 86400, // 24h
models: 43200, // 12h
groups: 21600, // 6h
catalogs: 86400,
models: 43200,
cars: 21600,
groups: 21600,
schemas: 7200,
schemaDetail: 7200,
};
@Injectable()
@@ -49,13 +88,19 @@ export class PcatCatalogService {
const rows = await this.db.execute<{
id: string;
name: string;
brand: string | null;
img_url: string | null;
models_count: number;
}>(sql`SELECT id, name, models_count FROM pc.catalogs WHERE is_active = true ORDER BY name`);
cars_count: number;
}>(sql`SELECT id, name, brand, img_url, models_count, cars_count FROM pc.catalogs WHERE is_active = true ORDER BY name`);
const result: PcatCatalogDto[] = rows.map((r) => ({
id: r.id,
name: r.name,
brand: r.brand,
imgUrl: r.img_url,
modelsCount: r.models_count ?? 0,
carsCount: r.cars_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.catalogs);
@@ -72,8 +117,11 @@ export class PcatCatalogService {
catalog_id: string;
name: string;
img_url: string | null;
year_from: number | null;
year_to: number | null;
cars_count: number;
}>(
sql`SELECT id, catalog_id, name, img_url FROM pc.models WHERE catalog_id = ${catalogId} AND is_active = true ORDER BY name`,
sql`SELECT id, catalog_id, name, img_url, year_from, year_to, cars_count FROM pc.models WHERE catalog_id = ${catalogId} AND is_active = true ORDER BY name`,
);
const result: PcatModelDto[] = rows.map((r) => ({
@@ -81,39 +129,81 @@ export class PcatCatalogService {
catalogId: r.catalog_id,
name: r.name,
imgUrl: r.img_url,
yearFrom: r.year_from,
yearTo: r.year_to,
carsCount: r.cars_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.models);
return result;
}
async getGroups(catalogId: string, parentId?: string): Promise<PcatGroupDto[]> {
const cacheKey = `pcat:groups:${catalogId}:${parentId || "root"}`;
async getCars(modelId: string): Promise<PcatCarDto[]> {
const cacheKey = `pcat:cars:${modelId}`;
const cached = await this.redis.getJson<PcatCarDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
model_id: string;
name: string;
year_from: number | null;
year_to: number | null;
engine: string | null;
transmission: string | null;
body_type: string | null;
fuel_type: string | null;
drive_type: string | null;
steering: string | null;
schemas_count: number;
parts_count: number;
}>(
sql`SELECT id, model_id, name, year_from, year_to, engine, transmission, body_type, fuel_type, drive_type, steering, schemas_count, parts_count
FROM pc.cars WHERE model_id = ${modelId} AND is_active = true
ORDER BY year_from DESC NULLS LAST, name`,
);
const result: PcatCarDto[] = rows.map((r) => ({
id: r.id,
modelId: r.model_id,
name: r.name,
yearFrom: r.year_from,
yearTo: r.year_to,
engine: r.engine,
transmission: r.transmission,
bodyType: r.body_type,
fuelType: r.fuel_type,
driveType: r.drive_type,
steering: r.steering,
schemasCount: r.schemas_count ?? 0,
partsCount: r.parts_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.cars);
return result;
}
async getCarGroups(carId: string, parentId?: string): Promise<PcatGroupDto[]> {
const cacheKey = `pcat:car-groups:${carId}:${parentId || "root"}`;
const cached = await this.redis.getJson<PcatGroupDto[]>(cacheKey);
if (cached) return cached;
const rows = parentId
? await this.db.execute<{
id: string;
catalog_id: string;
parent_id: string | null;
name: string;
img_url: string | null;
has_subgroups: boolean;
has_parts: boolean;
id: string; catalog_id: string; parent_id: string | null; name: string; img_url: string | null; has_subgroups: boolean; has_parts: boolean;
}>(
sql`SELECT id, catalog_id, parent_id, name, img_url, has_subgroups, has_parts FROM pc.groups WHERE catalog_id = ${catalogId} AND parent_id = ${parentId} ORDER BY sort_order, name`,
sql`SELECT DISTINCT g.id, g.catalog_id, g.parent_id, g.name, g.img_url, g.has_subgroups, g.has_parts
FROM pc.car_groups cg JOIN pc.groups g ON g.id = cg.group_id
WHERE cg.car_id = ${carId} AND g.parent_id = ${parentId}
ORDER BY g.name`,
)
: await this.db.execute<{
id: string;
catalog_id: string;
parent_id: string | null;
name: string;
img_url: string | null;
has_subgroups: boolean;
has_parts: boolean;
id: string; catalog_id: string; parent_id: string | null; name: string; img_url: string | null; has_subgroups: boolean; has_parts: boolean;
}>(
sql`SELECT id, catalog_id, parent_id, name, img_url, has_subgroups, has_parts FROM pc.groups WHERE catalog_id = ${catalogId} AND parent_id IS NULL ORDER BY sort_order, name`,
sql`SELECT DISTINCT g.id, g.catalog_id, g.parent_id, g.name, g.img_url, g.has_subgroups, g.has_parts
FROM pc.car_groups cg JOIN pc.groups g ON g.id = cg.group_id
WHERE cg.car_id = ${carId} AND g.parent_id IS NULL
ORDER BY g.name`,
);
const result: PcatGroupDto[] = rows.map((r) => ({
@@ -129,4 +219,119 @@ export class PcatCatalogService {
await this.redis.setJson(cacheKey, result, CACHE_TTL.groups);
return result;
}
async getSchemaImages(carId: string, groupId: string): Promise<PcatSchemaImageDto[]> {
const cacheKey = `pcat:schemas:${carId}:${groupId}`;
const cached = await this.redis.getJson<PcatSchemaImageDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: number; name: string | null; img_url: string | null; parts_count: number;
}>(
sql`SELECT id, name, img_url, parts_count FROM pc.schema_images
WHERE car_id = ${carId} AND group_id = ${groupId} AND is_active = true
ORDER BY name`,
);
const result: PcatSchemaImageDto[] = rows.map((r) => ({
id: String(r.id),
name: r.name,
imgUrl: r.img_url,
partsCount: r.parts_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.schemas);
return result;
}
async getSchemaDetail(schemaImageId: string): Promise<PcatSchemaDetailDto> {
const cacheKey = `pcat:schema-detail:${schemaImageId}`;
const cached = await this.redis.getJson<PcatSchemaDetailDto>(cacheKey);
if (cached) return cached;
const numId = parseInt(schemaImageId, 10);
// Get schema image info
const [image] = await this.db.execute<{
id: number; name: string | null; img_url: string | null; parts_count: number;
}>(sql`SELECT id, name, img_url, parts_count FROM pc.schema_images WHERE id = ${numId} LIMIT 1`);
if (!image) {
return { schemaImage: { id: schemaImageId, name: null, imgUrl: null, partsCount: 0 }, parts: [], schemaPics: [], hotspots: [] };
}
// Get parts via schema_parts junction
const partRows = await this.db.execute<{
sp_id: number; position_number: string | null; quantity: number;
sp_description: string | null; sp_notice: string | null;
part_id: number; part_number: string; part_name: string | null;
}>(
sql`SELECT sp.id as sp_id, sp.position_number, sp.quantity, sp.description as sp_description, sp.notice as sp_notice,
p.id as part_id, p.part_number, p.name as part_name
FROM pc.schema_parts sp JOIN pc.parts p ON p.id = sp.part_id
WHERE sp.schema_image_id = ${numId}
ORDER BY sp.position_number, p.name`,
);
// Get hotspots
const hotspotRows = await this.db.execute<{
id: number; position_number: string; x: number; y: number; width: number; height: number;
}>(
sql`SELECT id, position_number, x, y, width, height FROM pc.part_hotspots
WHERE schema_image_id = ${numId} ORDER BY position_number`,
);
// Calculate image dimensions from hotspot bounds
let imgWidth = 0;
let imgHeight = 0;
for (const h of hotspotRows) {
imgWidth = Math.max(imgWidth, h.x + h.width);
imgHeight = Math.max(imgHeight, h.y + h.height);
}
// Add 10% padding
imgWidth = Math.round(imgWidth * 1.1) || 1000;
imgHeight = Math.round(imgHeight * 1.1) || 800;
const imgUrl = image.img_url?.startsWith("//") ? `https:${image.img_url}` : (image.img_url || "");
const schemaImage: PcatSchemaImageDto = {
id: String(image.id),
name: image.name,
imgUrl: image.img_url,
partsCount: image.parts_count ?? 0,
};
const parts = partRows.map((r) => {
const pos = r.position_number ? parseInt(r.position_number, 10) : null;
return {
id: String(r.sp_id),
name: r.part_name || r.sp_description || "",
oemCode: r.part_number,
quantity: r.quantity ?? 1,
position: r.position_number,
hotspotIndex: isNaN(pos!) ? null : pos,
};
});
const schemaPics = [{
id: String(image.id),
url: imgUrl,
width: imgWidth,
height: imgHeight,
label: image.name || "",
}];
const hotspots = hotspotRows.map((h) => ({
id: String(h.id),
key: `hotspot-${h.id}`,
group: parseInt(h.position_number, 10) || 0,
shape: "rect" as const,
coordinates: [h.x, h.y, h.width, h.height],
label: h.position_number,
}));
const result: PcatSchemaDetailDto = { schemaImage, parts, schemaPics, hotspots };
await this.redis.setJson(cacheKey, result, CACHE_TTL.schemaDetail);
return result;
}
}