fix: SubGroup filtering now shows all parts correctly

- Removed pagination from getCategoryPartsByVin API endpoint
- API now returns all parts for a category (not just first 50)
- Frontend filters by subGroupId correctly with full dataset
- Removed debug console.log statements from frontend

Previously, only 2 parts would show when selecting a subgroup that had 67 parts
because the API was returning paginated results and the user's subgroup parts
weren't in the first page.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-01-27 07:16:27 +01:00
parent 242ef0d053
commit 384aea0a01
2 changed files with 246 additions and 61 deletions

View File

@@ -1,7 +1,11 @@
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { PaginationDto, PaginatedResponseDto } from '../../common/dto/pagination.dto';
import {
PaginationDto,
PaginatedResponseDto,
getSafeSortField,
} from '../../common/dto/pagination.dto';
import { VehicleFilterDto } from './dto/vehicle-filter.dto';
import { PL24Service, PL24DbService } from '../../integrations/pl24';
import { EmexService } from '../../integrations/emex';
@@ -41,8 +45,20 @@ interface FetchedPart {
hotspotId?: string;
// Model codes / PR codes for compatibility (e.g., "PR:1PD+F...FM4")
modelCodes?: string;
// SubGroup ID for filtering parts by subgroup
subGroupId?: string;
imageUrl: string | null;
brandPrices: Array<{ brand: string; price: number; currency: string; inStock: boolean }>;
// Part supersession (replacement info)
supersededBy?: string;
supersedes?: string;
// Part restrictions/conditions
restrictions?: string[];
// Preselected flag (recommended by PL24)
isPreselected?: boolean;
}
interface SubGroupWithParts {
@@ -87,7 +103,9 @@ export class VehiclesService {
) {}
async getUserVehicles(userId: string, pagination: PaginationDto, filter: VehicleFilterDto) {
const { page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = pagination;
const { page = 1, limit = 20, sortOrder = 'desc' } = pagination;
// Validate sortBy against whitelist to prevent arbitrary field injection
const sortBy = getSafeSortField(pagination.sortBy, 'vehicles', 'createdAt');
const skip = (page - 1) * limit;
const where: Prisma.VehicleWhereInput = {
@@ -218,11 +236,9 @@ export class VehiclesService {
include: {
categories: {
include: {
category: {
include: {
children: true,
},
},
// Remove children: true to prevent N+1 queries
// Children are rarely needed in category listings
category: true,
},
orderBy: { category: { sortOrder: 'asc' } },
},
@@ -243,7 +259,9 @@ export class VehiclesService {
}
async getVehicleParts(vehicleId: string, pagination: PaginationDto) {
const { page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = pagination;
const { page = 1, limit = 20, sortOrder = 'desc' } = pagination;
// Validate sortBy against whitelist to prevent arbitrary field injection
const sortBy = getSafeSortField(pagination.sortBy, 'parts', 'createdAt');
const skip = (page - 1) * limit;
const [parts, total] = await Promise.all([
@@ -296,26 +314,20 @@ export class VehiclesService {
}
// Get parts for this vehicle and category
const { page = 1, limit = 50, sortBy = 'oemCode', sortOrder = 'asc' } = pagination;
const skip = (page - 1) * limit;
// Note: We load ALL parts without pagination so frontend can filter by subGroup properly
const { sortOrder = 'asc' } = pagination;
// Validate sortBy against whitelist to prevent arbitrary field injection
const sortBy = getSafeSortField(pagination.sortBy, 'parts', 'oemCode');
// Fetch parts, count, and VehicleCategory (with SchemaPic for local path) together
let [parts, total, vehicleCategory] = await Promise.all([
// Fetch ALL parts and VehicleCategory (with SchemaPic for local path) together
let [parts, vehicleCategory] = await Promise.all([
this.prisma.part.findMany({
where: {
vehicleId: vehicle.id,
categoryId: categoryId,
},
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.part.count({
where: {
vehicleId: vehicle.id,
categoryId: categoryId,
},
}),
this.prisma.vehicleCategory.findUnique({
where: {
vehicleId_categoryId: {
@@ -336,7 +348,7 @@ export class VehiclesService {
const rawResponse = vehicle.rawResponse as VehicleRawResponse | null;
// If parts exist in DB, load subGroups from stored JSON
if (total > 0 && vehicleCategory?.subGroupsJson) {
if (parts.length > 0 && vehicleCategory?.subGroupsJson) {
try {
const storedSubGroups = vehicleCategory.subGroupsJson as Array<{
id: string;
@@ -366,7 +378,7 @@ export class VehiclesService {
}
// If no parts found, fetch on-demand based on source (PL24 or EMEX)
if (total === 0) {
if (parts.length === 0) {
if (rawResponse?.source === 'pl24' && rawResponse.catalogInfo) {
this.logger.log(`No parts in DB for VIN ${vin}, category ${category.code}. Fetching from PL24...`);
@@ -377,9 +389,8 @@ export class VehiclesService {
);
if (fetchResult.allParts.length > 0) {
// Apply pagination to fetched parts (use any to bridge Prisma and FetchedPart types)
parts = fetchResult.allParts.slice(skip, skip + limit) as any;
total = fetchResult.allParts.length;
// Return all parts (frontend handles subGroup filtering)
parts = fetchResult.allParts as any;
subGroups = fetchResult.subGroups;
// Reload vehicleCategory to get updated schemaPicId and hotspots
@@ -405,9 +416,8 @@ export class VehiclesService {
);
if (fetchResult.allParts.length > 0) {
// Apply pagination to fetched parts
parts = fetchResult.allParts.slice(skip, skip + limit) as any;
total = fetchResult.allParts.length;
// Return all parts (frontend handles filtering)
parts = fetchResult.allParts as any;
}
}
}
@@ -505,8 +515,14 @@ export class VehiclesService {
positionCode: p.positionCode,
hotspotId: p.hotspotId || null, // For linking to schema image hotspot
modelCodes: p.modelCodes || null,
subGroupId: p.subGroupId || null, // For filtering by subgroup
imageUrl: p.imageUrl,
prices: (p.brandPrices as Array<{ brand: string; price: number; currency: string; inStock: boolean }>) || [],
// New fields
supersededBy: p.supersededBy || null, // New part number that replaces this one
supersedes: p.supersedes || null, // Old part number that this one replaces
restrictions: p.restrictions || null, // Array of restriction strings
isPreselected: p.isPreselected || false, // Recommended part flag
};
}),
// Hotspot coordinates for schema image interaction
@@ -519,7 +535,7 @@ export class VehiclesService {
width: vehicleCategory.schemaPic.width,
height: vehicleCategory.schemaPic.height,
} : null,
totalParts: total,
totalParts: parts.length,
};
}
@@ -662,6 +678,13 @@ export class VehiclesService {
const subGroupParts: SubGroupWithParts['parts'] = [];
for (const p of partsResponse.parts) {
// Extract supersession info
const supersededBy = p.superseded?.newCode !== p.oemCode ? p.superseded?.newCode : undefined;
const supersedes = p.superseded?.oldCode && p.superseded.oldCode !== p.oemCode ? p.superseded.oldCode : undefined;
// Check if preselected
const isPreselected = p.additionalInfo?.preselected === 'true';
const partData: FetchedPart = {
id: `pl24-${vehicle.id}-${p.id}`,
oemCode: p.oemCode,
@@ -675,8 +698,14 @@ export class VehiclesService {
positionCode: p.positionCode || null,
hotspotId: p.hotspotId || undefined, // Link to schema image hotspot
modelCodes: p.modelCodes,
subGroupId: subGroup.id, // Associate part with its subgroup
imageUrl: null,
brandPrices: [],
// New fields
supersededBy,
supersedes,
restrictions: p.restrictions,
isPreselected,
};
allParts.push(partData);
subGroupParts.push(partData);
@@ -763,6 +792,7 @@ export class VehiclesService {
data: allParts.map((p) => ({
vehicleId: vehicle.id,
categoryId: category.id,
subGroupId: p.subGroupId || null, // Associate with subgroup for filtering
oemCode: p.oemCode,
oemCodes: p.oemCodes,
nameEn: p.nameEn,
@@ -770,14 +800,27 @@ export class VehiclesService {
description: p.description,
positionCode: p.positionCode,
hotspotId: p.hotspotId || null, // Link to schema image hotspot
remark: p.remark || null,
quantity: p.quantity || null,
modelCodes: p.modelCodes || null,
brandPrices: JSON.parse(JSON.stringify(p.brandPrices)),
imageUrl: p.imageUrl,
// New fields
formattedPartNo: p.formattedPartNo || null,
supersededBy: p.supersededBy || null,
supersedes: p.supersedes || null,
restrictions: p.restrictions ? JSON.parse(JSON.stringify(p.restrictions)) : null,
isPreselected: p.isPreselected || false,
})),
skipDuplicates: true,
});
// Update vehicle category part count, schema URL, and subGroups
const updateData: { partCount: number; schemaImageUrl?: string; subGroupsJson?: any } = {
const updateData: {
partCount: number;
schemaImageUrl?: string;
subGroupsJson?: Prisma.InputJsonValue;
} = {
partCount: allParts.length,
};
@@ -797,7 +840,7 @@ export class VehiclesService {
schemaWidth: sg.schemaWidth || null,
schemaHeight: sg.schemaHeight || null,
hotspots: sg.hotspots || null,
}));
})) as Prisma.InputJsonValue;
}
await tx.vehicleCategory.updateMany({