fix bug of edit product

This commit is contained in:
2026-09-30 21:16:29 +03:30
parent 38cabb0957
commit be00478aa4
15 changed files with 150 additions and 119 deletions
+33
View File
@@ -0,0 +1,33 @@
import {
colorVariantImageLists,
parseColorEntries,
parseGeneralImageUrls,
} from './color-entries';
describe('product image separation', () => {
it('keeps general images separate from color variant urls', () => {
const entries = parseColorEntries(
[
{ name: 'کرم', imageUrls: ['https://cdn/a.jpg', 'https://cdn/b.jpg'] },
{ name: 'طوسی', imageUrl: 'https://cdn/grey.jpg' },
],
'کرم',
);
const general = parseGeneralImageUrls(
['https://cdn/g1.jpg', 'https://cdn/a.jpg'],
entries,
);
expect(general).toEqual(['https://cdn/g1.jpg']);
expect(colorVariantImageLists(entries)).toEqual([
['https://cdn/a.jpg', 'https://cdn/b.jpg'],
['https://cdn/grey.jpg'],
]);
});
it('does not merge color images into general list when only imageUrlsJson is set', () => {
const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]);
expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([
'https://cdn/g.jpg',
]);
});
});
+53 -16
View File
@@ -1,48 +1,85 @@
export type ProductColorEntry = {
name: string;
/** @deprecated use imageUrls — first image for swatches */
imageUrl?: string;
imageUrls: string[];
};
function normalizeUrlList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const item of raw) {
if (typeof item !== 'string') continue;
const url = item.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
export function parseColorEntries(
colorsJson: unknown,
fallbackColor?: string,
): ProductColorEntry[] {
if (!Array.isArray(colorsJson) || colorsJson.length === 0) {
const name = fallbackColor?.trim();
return name ? [{ name }] : [];
return name ? [{ name, imageUrls: [] }] : [];
}
return colorsJson
.map((item): ProductColorEntry | null => {
if (typeof item === 'string') {
const name = item.trim();
return name ? { name } : null;
return name ? { name, imageUrls: [] } : null;
}
if (item && typeof item === 'object') {
const raw = item as { name?: unknown; imageUrl?: unknown };
const raw = item as {
name?: unknown;
imageUrl?: unknown;
imageUrls?: unknown;
};
const name = String(raw.name ?? '').trim();
if (!name) return null;
const imageUrl =
const fromArray = normalizeUrlList(raw.imageUrls);
const legacy =
typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
? raw.imageUrl
? raw.imageUrl.trim()
: undefined;
return { name, imageUrl };
const imageUrls =
fromArray.length > 0
? legacy && !fromArray.includes(legacy)
? [legacy, ...fromArray]
: fromArray
: legacy
? [legacy]
: [];
const imageUrl = imageUrls[0];
return { name, imageUrl, imageUrls };
}
return null;
})
.filter((entry): entry is ProductColorEntry => Boolean(entry));
}
export function mergeProductImages(
entries: ProductColorEntry[],
/** تصاویر عمومی محصول — فقط از imageUrlsJson، بدون merge با رنگ‌ها */
export function parseGeneralImageUrls(
imageUrlsJson: unknown,
colorEntries?: ProductColorEntry[],
): string[] {
const extras = Array.isArray(imageUrlsJson)
? imageUrlsJson.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
: [];
const fromColors = entries
.map((entry) => entry.imageUrl)
.filter((url): url is string => Boolean(url));
const seen = new Set(fromColors);
return [...fromColors, ...extras.filter((url) => !seen.has(url))];
const general = normalizeUrlList(imageUrlsJson);
if (!colorEntries?.length) return general;
const colorOnly = new Set<string>();
for (const entry of colorEntries) {
for (const url of entry.imageUrls) colorOnly.add(url);
}
return general.filter((url) => !colorOnly.has(url));
}
export function colorVariantImageLists(
entries: ProductColorEntry[],
): string[][] {
return entries.map((entry) => [...entry.imageUrls]);
}
+16 -2
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
export class ProductColorJsonItemDto {
@IsString()
@@ -8,6 +8,11 @@ export class ProductColorJsonItemDto {
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
imageUrls?: string[];
}
/** نرمال‌سازی colorsJson (رشتهٔ legacy یا آبجکت ادمین) قبل از اعتبارسنجی */
@@ -26,11 +31,20 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
const raw = item as Record<string, unknown>;
const name = String(raw.name ?? '').trim();
if (!name) continue;
const imageUrls = Array.isArray(raw.imageUrls)
? raw.imageUrls
.filter((u): u is string => typeof u === 'string' && u.trim().length > 0)
.map((u) => u.trim())
: undefined;
const imageUrl =
typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
? raw.imageUrl.trim()
: undefined;
out.push({ name, imageUrl });
out.push({
name,
imageUrl,
imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined,
});
}
}
return out;
+2 -8
View File
@@ -18,9 +18,6 @@ export class CreateProductDto {
@IsString()
companyId!: string;
@IsString()
catalogId!: string;
@IsOptional()
@IsString()
brandId?: string | null;
@@ -69,9 +66,10 @@ export class CreateProductDto {
@Type(() => Number)
@IsNumber()
@Min(0)
/** قیمت هر متر (تومان) */
basePrice?: number | null;
/** Alias from admin UI */
/** Alias from admin UI — قیمت هر متر (تومان) */
@IsOptional()
@Type(() => Number)
@IsNumber()
@@ -154,10 +152,6 @@ export class CreateProductDto {
}
export class UpdateProductDto {
@IsOptional()
@IsString()
catalogId?: string;
@IsOptional()
@IsString()
brandId?: string | null;
-2
View File
@@ -32,7 +32,6 @@ export class ProductsController {
@Get()
findAll(
@CurrentUser() user: AuthPrincipal | undefined,
@Query('catalogId') catalogId?: string,
@Query('companyId') companyId?: string,
@Query('color') color?: string,
@Query('activeOnly') activeOnly?: string,
@@ -42,7 +41,6 @@ export class ProductsController {
) {
const isAdmin = user?.type === 'admin';
return this.productsService.findAll({
catalogId,
companyId,
color,
activeOnly: activeOnly === 'true' || activeOnly === '1',
+18 -21
View File
@@ -8,11 +8,14 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
import { mergeProductImages, parseColorEntries } from './color-entries';
import {
colorVariantImageLists,
parseColorEntries,
parseGeneralImageUrls,
} from './color-entries';
const productInclude = {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
brand: { select: { id: true, name: true } },
};
@@ -25,7 +28,6 @@ export class ProductsService {
}
async findAll(filters: {
catalogId?: string;
companyId?: string;
color?: string;
activeOnly?: boolean;
@@ -35,7 +37,6 @@ export class ProductsService {
}) {
const products = await this.prisma.product.findMany({
where: {
...(filters.catalogId ? { catalogId: filters.catalogId } : {}),
...(filters.companyId ? { companyId: filters.companyId } : {}),
...(filters.color
? { color: { contains: filters.color, mode: 'insensitive' } }
@@ -75,14 +76,6 @@ export class ProductsService {
}
async create(dto: CreateProductDto) {
const catalog = await this.prisma.catalog.findUnique({
where: { id: dto.catalogId },
});
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
if (catalog.companyId !== dto.companyId) {
throw new ConflictException('کاتالوگ متعلق به این شرکت نیست');
}
const code = (dto.code || dto.productCode || '').trim();
if (!code) throw new ConflictException('کد محصول الزامی است');
const brandId = await this.resolveBrandId(dto.brandId ?? null);
@@ -91,7 +84,6 @@ export class ProductsService {
const product = await this.prisma.product.create({
data: {
companyId: dto.companyId,
catalogId: dto.catalogId,
brandId,
name: dto.name.trim(),
code,
@@ -145,7 +137,6 @@ export class ProductsService {
const product = await this.prisma.product.update({
where: { id },
data: {
catalogId: dto.catalogId,
...(brandId !== undefined ? { brandId } : {}),
name: dto.name?.trim(),
code,
@@ -290,7 +281,6 @@ export class ProductsService {
product: {
id: string;
companyId: string;
catalogId: string;
name: string;
code: string;
color: string;
@@ -303,7 +293,6 @@ export class ProductsService {
createdAt: Date;
updatedAt: Date;
company: { name: string };
catalog: { code: string; name: string };
brand?: { id: string; name: string } | null;
usageCategory?: string;
season?: string;
@@ -322,8 +311,14 @@ export class ProductsService {
) {
const colorEntries = parseColorEntries(product.colorsJson, product.color);
const colors = colorEntries.map((entry) => entry.name);
const colorImages = colorEntries.map((entry) => entry.imageUrl ?? null);
const imageUrls = mergeProductImages(colorEntries, product.imageUrlsJson);
const colorImages = colorEntries.map(
(entry) => entry.imageUrls[0] ?? entry.imageUrl ?? null,
);
const colorVariantImages = colorVariantImageLists(colorEntries);
const imageUrls = parseGeneralImageUrls(
product.imageUrlsJson,
colorEntries,
);
const tags = Array.isArray(product.tagsJson)
? (product.tagsJson as string[])
: [];
@@ -332,9 +327,10 @@ export class ProductsService {
id: product.id,
companyId: product.companyId,
companyName: product.company.name,
catalogId: product.catalogId,
catalogCode: product.catalog.code,
catalogName: product.catalog.name,
/** @deprecated use `code` — kept for existing clients */
catalogCode: product.code,
/** @deprecated use `name` */
catalogName: product.name,
brandId: product.brand?.id ?? null,
brandName: product.brand?.name ?? null,
name: product.name,
@@ -359,6 +355,7 @@ export class ProductsService {
weaveType: product.weaveType ?? undefined,
colors,
colorImages,
colorVariantImages,
tags,
imageUrls,
subtitle: product.subtitle ?? undefined,