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
@@ -0,0 +1,5 @@
-- Remove Catalog as independent entity; Product is the sole merchandise unit.
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_catalogId_fkey";
DROP INDEX IF EXISTS "Product_catalogId_idx";
ALTER TABLE "Product" DROP COLUMN IF EXISTS "catalogId";
DROP TABLE IF EXISTS "Catalog";
+1 -23
View File
@@ -143,7 +143,6 @@ model Company {
updatedAt DateTime @updatedAt
bankAccounts BankAccount[]
catalogs Catalog[]
products Product[]
orders Order[]
customers Customer[]
@@ -169,35 +168,16 @@ model BankAccount {
@@index([companyId])
}
model Catalog {
id String @id @default(cuid())
companyId String
name String
code String
description String?
imageUrl String?
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
products Product[]
@@unique([companyId, code])
@@index([companyId])
}
model Product {
id String @id @default(cuid())
companyId String
catalogId String
name String
code String
color String
description String?
minMeterage Float @default(30)
orderMultiplier Float @default(10)
/// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور
basePrice Float?
stock Float?
isActive Boolean @default(true)
@@ -219,7 +199,6 @@ model Product {
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
catalog Catalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
cartItems CartItem[]
orderItems OrderItem[]
@@ -229,7 +208,6 @@ model Product {
productSuggestions ProductSuggestion[]
@@unique([companyId, code])
@@index([catalogId])
@@index([companyId])
@@index([brandId])
@@index([usageCategory])
-21
View File
@@ -40,7 +40,6 @@ async function main() {
await prisma.otpChallenge.deleteMany();
await prisma.customerNotification.deleteMany();
await prisma.product.deleteMany();
await prisma.catalog.deleteMany();
await prisma.bankAccount.deleteMany();
await prisma.levelBenefit.deleteMany();
await prisma.customerLevelDef.deleteMany();
@@ -130,24 +129,11 @@ async function main() {
],
});
await prisma.catalog.createMany({
data: [
{ id: 'cat-377', companyId: 'company-par', name: 'کالیته ۳۷۷', code: '377', displayOrder: 1, isActive: true },
{ id: 'cat-378', companyId: 'company-par', name: 'کالیته ۳۷۸', code: '378', displayOrder: 2, isActive: true },
{ id: 'cat-379', companyId: 'company-par', name: 'کالیته ۳۷۹', code: '379', displayOrder: 3, isActive: true },
{ id: 'cat-380', companyId: 'company-par', name: 'کالیته ۳۸۰', code: '380', displayOrder: 4, isActive: true },
{ id: 'cat-381', companyId: 'company-par', name: 'کالیته ۳۸۱', code: '381', displayOrder: 5, isActive: true },
{ id: 'cat-fam-401', companyId: 'company-fam-baft', name: 'کالیته ۴۰۱', code: '401', displayOrder: 1, isActive: true },
{ id: 'cat-fam-402', companyId: 'company-fam-baft', name: 'کالیته ۴۰۲', code: '402', displayOrder: 2, isActive: true },
],
});
await prisma.product.createMany({
data: [
{
id: 'prod-377-blue',
companyId: 'company-par',
catalogId: 'cat-377',
name: 'فاستونی ایتالیایی',
code: '377-BL',
color: 'آبی',
@@ -171,7 +157,6 @@ async function main() {
{
id: 'prod-377-black',
companyId: 'company-par',
catalogId: 'cat-377',
name: 'پارچه مشکی رسمی',
code: '377-BK',
color: 'مشکی',
@@ -195,7 +180,6 @@ async function main() {
{
id: 'prod-378-blue',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'پیراهنی نخی آبی',
code: '378-BL',
color: 'آبی',
@@ -219,7 +203,6 @@ async function main() {
{
id: 'prod-378-black',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'پیراهنی مشکی',
code: '378-BK',
color: 'مشکی',
@@ -242,7 +225,6 @@ async function main() {
{
id: 'prod-378-blue-white',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'آبی پشت سفید',
code: '378-BLW',
color: 'آبی پشت سفید',
@@ -265,7 +247,6 @@ async function main() {
{
id: 'prod-378-black-white',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'مشکی پشت سفید',
code: '378-BKW',
color: 'مشکی پشت سفید',
@@ -288,7 +269,6 @@ async function main() {
{
id: 'prod-fam-401-1',
companyId: 'company-fam-baft',
catalogId: 'cat-fam-401',
name: 'پارچه مخمل',
code: '401-MV',
color: 'سرمه‌ای',
@@ -311,7 +291,6 @@ async function main() {
{
id: 'prod-fam-401-2',
companyId: 'company-fam-baft',
catalogId: 'cat-fam-401',
name: 'پارچه کجراه',
code: '401-KJ',
color: 'کرم',
-2
View File
@@ -6,7 +6,6 @@ import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { CompaniesModule } from './companies/companies.module';
import { CatalogsModule } from './catalogs/catalogs.module';
import { ProductsModule } from './products/products.module';
import { CustomersModule } from './customers/customers.module';
import { AdminUsersModule } from './admin-users/admin-users.module';
@@ -35,7 +34,6 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo
PrismaModule,
AuthModule,
CompaniesModule,
CatalogsModule,
ProductsModule,
CustomersModule,
AdminUsersModule,
+1 -2
View File
@@ -23,7 +23,6 @@ export class CartsService {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
include: {
catalog: true,
company: true,
},
});
@@ -54,7 +53,7 @@ export class CartsService {
data: {
cartId: cart.id,
productId: product.id,
catalogCode: product.catalog.code,
catalogCode: product.code,
productName: product.name,
productCode: product.code,
color: product.color,
+6 -7
View File
@@ -24,7 +24,7 @@ export class CompaniesService {
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: {
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
select: { products: true, bankAccounts: true },
},
},
}),
@@ -40,7 +40,7 @@ export class CompaniesService {
include: {
bankAccounts: { orderBy: { displayOrder: 'asc' } },
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
select: { products: true, bankAccounts: true },
},
},
}),
@@ -78,7 +78,7 @@ export class CompaniesService {
},
include: {
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
select: { products: true, bankAccounts: true },
},
},
});
@@ -116,7 +116,7 @@ export class CompaniesService {
},
include: {
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
select: { products: true, bankAccounts: true },
},
},
});
@@ -139,7 +139,7 @@ export class CompaniesService {
data: { isActive },
include: {
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
select: { products: true, bankAccounts: true },
},
},
});
@@ -226,7 +226,7 @@ export class CompaniesService {
cashInvoiceValidUntilHour: number | null;
createdAt: Date;
updatedAt: Date;
_count?: { catalogs: number; products: number; bankAccounts: number };
_count?: { products: number; bankAccounts: number };
},
fallbackMaxMeterage: number | null = null,
) {
@@ -247,7 +247,6 @@ export class CompaniesService {
ownMaxMeteragePerInvoice: company.maxMeteragePerInvoice ?? undefined,
cashInvoiceValidityHours: company.cashInvoiceValidityHours,
cashInvoiceValidUntilHour: company.cashInvoiceValidUntilHour ?? undefined,
catalogCount: company._count?.catalogs ?? 0,
productCount: company._count?.products ?? 0,
bankAccountCount: company._count?.bankAccounts ?? 0,
createdAt: company.createdAt.toISOString(),
+2 -2
View File
@@ -736,7 +736,7 @@ export class OrdersService {
const products = await this.prisma.product.findMany({
where: { id: { in: uniqueProductIds } },
include: { catalog: true, company: true },
include: { company: true },
});
const productMap = new Map(products.map((p) => [p.id, p]));
@@ -818,7 +818,7 @@ export class OrdersService {
totalMeterage += item.meterage;
return {
productId: product.id,
catalogCode: product.catalog.code,
catalogCode: product.code,
productName: product.name,
productCode: product.code,
color: product.color,
+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,
+1 -3
View File
@@ -18,7 +18,6 @@ export class StockNotifyService {
color: true,
stock: true,
imageUrlsJson: true,
catalog: { select: { code: true } },
},
},
},
@@ -110,7 +109,6 @@ export class StockNotifyService {
color: string;
stock: number | null;
imageUrlsJson: unknown;
catalog: { code: string };
};
}) {
return {
@@ -118,7 +116,7 @@ export class StockNotifyService {
productId: r.productId,
productName: r.product.name,
productCode: r.product.code,
catalogCode: r.product.catalog.code,
catalogCode: r.product.code,
color: r.product.color,
stock: r.product.stock,
imageUrl: this.productThumb(r.product.imageUrlsJson),
+12 -10
View File
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { NotificationTone } from '@prisma/client';
import { NotificationsService } from '../notifications/notifications.service';
import {
mergeProductImages,
parseGeneralImageUrls,
parseColorEntries,
} from '../products/color-entries';
import { PrismaService } from '../prisma/prisma.service';
@@ -19,7 +19,6 @@ const PRODUCT_INCLUDE = {
imageUrlsJson: true,
widthCm: true,
composition: true,
catalog: { select: { name: true, code: true } },
},
},
customer: { select: { firstName: true, lastName: true, phone: true } },
@@ -147,17 +146,20 @@ export class SuggestionsService {
imageUrlsJson: unknown;
widthCm: number | null;
composition: string | null;
catalog: { name: string; code: string } | null;
};
}) {
const colorEntries = parseColorEntries(r.product.colorsJson, r.product.color);
const images = mergeProductImages(colorEntries, r.product.imageUrlsJson);
const imageUrls = parseGeneralImageUrls(
r.product.imageUrlsJson,
colorEntries,
);
const colors = colorEntries.map((entry) => entry.name);
const colorImages = colorEntries.map((entry) => entry.imageUrl ?? null);
const catalogName = r.product.catalog?.name;
const colorImages = colorEntries.map(
(entry) => entry.imageUrls[0] ?? entry.imageUrl ?? null,
);
const subtitle =
r.product.subtitle?.trim() ||
[catalogName, r.product.color].filter(Boolean).join(' · ') ||
[r.product.name, r.product.color].filter(Boolean).join(' · ') ||
undefined;
return {
@@ -168,11 +170,11 @@ export class SuggestionsService {
productId: r.productId,
productName: r.product.name,
productCode: r.product.code,
catalogName,
catalogCode: r.product.catalog?.code,
catalogName: r.product.name,
catalogCode: r.product.code,
color: r.product.color,
subtitle,
imageUrl: images[0] ?? undefined,
imageUrl: imageUrls[0] ?? colorImages.find(Boolean) ?? undefined,
colors,
colorImages,
widthCm: r.product.widthCm ?? undefined,