feat: pcat v2 — full catalog browsing with cars, groups, schema viewer
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user