Initial admin edits

This commit is contained in:
2026-10-01 21:02:58 +03:30
parent ac0cd63dbd
commit 301f3ff1c8
50 changed files with 1191 additions and 867 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AppBanner" ADD COLUMN "bannerLink" TEXT;
@@ -0,0 +1,23 @@
-- Preserve customer ↔ company links where only CustomerBrand existed (match by brand/company name or slug)
UPDATE "Customer" c
SET "companyId" = comp.id
FROM "CustomerBrand" cb
JOIN "Brand" b ON b.id = cb."brandId"
JOIN "Company" comp ON LOWER(TRIM(comp.name)) = LOWER(TRIM(b.name))
WHERE cb."customerId" = c.id
AND c."companyId" IS NULL;
UPDATE "Customer" c
SET "companyId" = comp.id
FROM "CustomerBrand" cb
JOIN "Brand" b ON b.id = cb."brandId"
JOIN "Company" comp ON b.slug IS NOT NULL AND comp.slug = b.slug
WHERE cb."customerId" = c.id
AND c."companyId" IS NULL;
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_brandId_fkey";
DROP INDEX IF EXISTS "Product_brandId_idx";
ALTER TABLE "Product" DROP COLUMN IF EXISTS "brandId";
DROP TABLE IF EXISTS "CustomerBrand";
DROP TABLE IF EXISTS "Brand";
@@ -0,0 +1,30 @@
-- نادمی تکس: شرکت اصلی (دریافت پرداخت + پیش‌فرض محصولات بدون شرکت مشخص)
INSERT INTO "Company" (
"id",
"name",
"slug",
"description",
"isActive",
"displayOrder",
"cashInvoiceValidityHours",
"updatedAt"
)
VALUES (
'company-nademitex',
'نادمی تکس',
'nademitex',
'شرکت اصلی — تمام پرداخت‌های اپ به حساب‌های این شرکت واریز می‌شود',
true,
0,
8,
CURRENT_TIMESTAMP
)
ON CONFLICT ("slug") DO UPDATE SET
"name" = EXCLUDED."name",
"description" = EXCLUDED."description",
"isActive" = true;
-- حساب‌های بانکی فقط برای دریافت پرداخت نادمی تکس
UPDATE "BankAccount"
SET "companyId" = (SELECT "id" FROM "Company" WHERE "slug" = 'nademitex' LIMIT 1)
WHERE "companyId" IS NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "specs" TEXT;
@@ -0,0 +1,3 @@
ALTER TABLE "SystemSettings" ADD COLUMN "supportWhatsapp" TEXT;
ALTER TABLE "SystemSettings" ADD COLUMN "supportTelegram" TEXT;
ALTER TABLE "SystemSettings" ADD COLUMN "supportInstagram" TEXT;
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "SettlementType" ADD VALUE 'cash_check';
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "defaultSettlementCashCheck" BOOLEAN NOT NULL DEFAULT false;
+11 -32
View File
@@ -11,6 +11,7 @@ datasource db {
enum SettlementType {
cash
check
cash_check
}
enum CheckRegistrationMode {
@@ -175,6 +176,8 @@ model Product {
code String
color String
description String?
/// مشخصات آزاد محصول (نمایش در specs-extra اپ)
specs String? @db.Text
minMeterage Float @default(30)
orderMultiplier Float @default(10)
/// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور
@@ -194,12 +197,10 @@ model Product {
subtitle String?
rollMeters Float @default(50)
isNew Boolean @default(false)
brandId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
cartItems CartItem[]
orderItems OrderItem[]
favorites ProductFavorite[]
@@ -208,7 +209,6 @@ model Product {
@@unique([companyId, code])
@@index([companyId])
@@index([brandId])
@@index([usageCategory])
@@index([season])
}
@@ -254,7 +254,6 @@ model Customer {
favorites ProductFavorite[]
catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
addresses CustomerAddress[]
@@index([companyId])
@@ -485,8 +484,9 @@ model SystemSettings {
id String @id @default("default")
maxMeteragePerInvoice Int @default(5000)
cashInvoiceValidityHours Int @default(8)
defaultSettlementCheck Boolean @default(true)
defaultSettlementCash Boolean @default(true)
defaultSettlementCheck Boolean @default(true)
defaultSettlementCash Boolean @default(true)
defaultSettlementCashCheck Boolean @default(false)
/// حداقل سفارش: تعداد طاقه از هر محصول (§۱۹)
minOrderRollsPerProduct Int @default(1)
/// نمایش قیمت عمومی در کاتالوگ (پیش‌فرض خاموش)
@@ -502,6 +502,9 @@ model SystemSettings {
smsShippingUpdate Boolean @default(true)
supportPhone String?
supportEmail String?
supportWhatsapp String?
supportTelegram String?
supportInstagram String?
/// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
categoryImagesJson Json?
/// تعاریف دسته‌ها (کاربرد/فصل). اگر خالی باشد از مقادیر پیش‌فرض کد استفاده می‌شود.
@@ -517,6 +520,8 @@ model AppBanner {
subtitle String?
ctaLabel String?
ctaLink String?
/// لینک کلیک روی تصویر بنر (مستقل از لینک دکمه)
bannerLink String?
imageUrl String?
displayOrder Int @default(0)
isActive Boolean @default(true)
@@ -627,32 +632,6 @@ model ProductFavorite {
@@index([customerId])
}
model Brand {
id String @id @default(cuid())
name String
slug String? @unique
description String?
isActive Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customers CustomerBrand[]
products Product[]
}
model CustomerBrand {
customerId String
brandId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
@@id([customerId, brandId])
@@index([brandId])
}
model CustomerLevelDef {
id String @id @default(cuid())
code String @unique
+17 -6
View File
@@ -66,6 +66,17 @@ async function main() {
await prisma.company.createMany({
data: [
{
id: 'company-nademitex',
name: 'نادمی تکس',
slug: 'nademitex',
description: 'شرکت اصلی — دریافت تمام پرداخت‌های اپ',
isActive: true,
displayOrder: 0,
maxMeteragePerInvoice: null,
cashInvoiceValidityHours: 8,
cashInvoiceValidUntilHour: 17,
},
{
id: 'company-par',
name: 'شرکت پَر',
@@ -95,9 +106,9 @@ async function main() {
data: [
{
id: 'ba-par-1',
companyId: 'company-par',
companyId: 'company-nademitex',
bankName: 'بانک ملی',
accountHolder: 'شرکت پَر',
accountHolder: 'نادمی تکس',
accountNumber: '0123456789',
cardNumber: '6037-9912-3456-7890',
iban: 'IR120170000000012345678901',
@@ -106,9 +117,9 @@ async function main() {
},
{
id: 'ba-par-2',
companyId: 'company-par',
companyId: 'company-nademitex',
bankName: 'بانک صادرات',
accountHolder: 'شرکت پَر',
accountHolder: 'نادمی تکس',
accountNumber: '9876543210',
cardNumber: '6037-6912-3456-7891',
iban: 'IR620190000000098765432101',
@@ -117,9 +128,9 @@ async function main() {
},
{
id: 'ba-fam-1',
companyId: 'company-fam-baft',
companyId: 'company-nademitex',
bankName: 'بانک ملت',
accountHolder: 'فام‌بافت',
accountHolder: 'نادمی تکس',
accountNumber: '1122334455',
cardNumber: '6104-3378-9012-3456',
iban: 'IR450120000000011223344556',
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AppBanner" ADD COLUMN "bannerLink" TEXT;
@@ -0,0 +1,23 @@
-- Preserve customer ↔ company links where only CustomerBrand existed (match by brand/company name or slug)
UPDATE "Customer" c
SET "companyId" = comp.id
FROM "CustomerBrand" cb
JOIN "Brand" b ON b.id = cb."brandId"
JOIN "Company" comp ON LOWER(TRIM(comp.name)) = LOWER(TRIM(b.name))
WHERE cb."customerId" = c.id
AND c."companyId" IS NULL;
UPDATE "Customer" c
SET "companyId" = comp.id
FROM "CustomerBrand" cb
JOIN "Brand" b ON b.id = cb."brandId"
JOIN "Company" comp ON b.slug IS NOT NULL AND comp.slug = b.slug
WHERE cb."customerId" = c.id
AND c."companyId" IS NULL;
ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_brandId_fkey";
DROP INDEX IF EXISTS "Product_brandId_idx";
ALTER TABLE "Product" DROP COLUMN IF EXISTS "brandId";
DROP TABLE IF EXISTS "CustomerBrand";
DROP TABLE IF EXISTS "Brand";
@@ -0,0 +1,30 @@
-- نادمی تکس: شرکت اصلی (دریافت پرداخت + پیش‌فرض محصولات بدون شرکت مشخص)
INSERT INTO "Company" (
"id",
"name",
"slug",
"description",
"isActive",
"displayOrder",
"cashInvoiceValidityHours",
"updatedAt"
)
VALUES (
'company-nademitex',
'نادمی تکس',
'nademitex',
'شرکت اصلی — تمام پرداخت‌های اپ به حساب‌های این شرکت واریز می‌شود',
true,
0,
8,
CURRENT_TIMESTAMP
)
ON CONFLICT ("slug") DO UPDATE SET
"name" = EXCLUDED."name",
"description" = EXCLUDED."description",
"isActive" = true;
-- حساب‌های بانکی فقط برای دریافت پرداخت نادمی تکس
UPDATE "BankAccount"
SET "companyId" = (SELECT "id" FROM "Company" WHERE "slug" = 'nademitex' LIMIT 1)
WHERE "companyId" IS NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "specs" TEXT;
@@ -0,0 +1,3 @@
ALTER TABLE "SystemSettings" ADD COLUMN "supportWhatsapp" TEXT;
ALTER TABLE "SystemSettings" ADD COLUMN "supportTelegram" TEXT;
ALTER TABLE "SystemSettings" ADD COLUMN "supportInstagram" TEXT;
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "SettlementType" ADD VALUE 'cash_check';
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "defaultSettlementCashCheck" BOOLEAN NOT NULL DEFAULT false;
+28 -49
View File
@@ -11,6 +11,7 @@ datasource db {
enum SettlementType {
cash
check
cash_check
}
enum CheckRegistrationMode {
@@ -175,6 +176,8 @@ model Product {
code String
color String
description String?
/// مشخصات آزاد محصول (نمایش در specs-extra اپ)
specs String? @db.Text
minMeterage Float @default(30)
orderMultiplier Float @default(10)
/// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور
@@ -194,12 +197,10 @@ model Product {
subtitle String?
rollMeters Float @default(50)
isNew Boolean @default(false)
brandId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
cartItems CartItem[]
orderItems OrderItem[]
favorites ProductFavorite[]
@@ -208,7 +209,6 @@ model Product {
@@unique([companyId, code])
@@index([companyId])
@@index([brandId])
@@index([usageCategory])
@@index([season])
}
@@ -254,7 +254,6 @@ model Customer {
favorites ProductFavorite[]
catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
addresses CustomerAddress[]
@@index([companyId])
@@ -482,31 +481,35 @@ model Shipment {
}
model SystemSettings {
id String @id @default("default")
maxMeteragePerInvoice Int @default(5000)
cashInvoiceValidityHours Int @default(8)
defaultSettlementCheck Boolean @default(true)
defaultSettlementCash Boolean @default(true)
id String @id @default("default")
maxMeteragePerInvoice Int @default(5000)
cashInvoiceValidityHours Int @default(8)
defaultSettlementCheck Boolean @default(true)
defaultSettlementCash Boolean @default(true)
defaultSettlementCashCheck Boolean @default(false)
/// حداقل سفارش: تعداد طاقه از هر محصول (§۱۹)
minOrderRollsPerProduct Int @default(1)
minOrderRollsPerProduct Int @default(1)
/// نمایش قیمت عمومی در کاتالوگ (پیش‌فرض خاموش)
showCatalogPublicPrice Boolean @default(false)
showCatalogPublicPrice Boolean @default(false)
/// اجازه قیمت‌گذاری توسط واسطه (§۱۹)
allowAgentPricing Boolean @default(false)
allowAgentPricing Boolean @default(false)
/// کف قیمت واحد برای قیمت‌گذاری واسطه (ریال) — اختیاری
agentMinUnitPrice Int?
smsOrderRegistered Boolean @default(true)
smsPricingReady Boolean @default(true)
smsPaymentReminder Boolean @default(true)
smsCheckReview Boolean @default(true)
smsShippingUpdate Boolean @default(true)
supportPhone String?
supportEmail String?
agentMinUnitPrice Int?
smsOrderRegistered Boolean @default(true)
smsPricingReady Boolean @default(true)
smsPaymentReminder Boolean @default(true)
smsCheckReview Boolean @default(true)
smsShippingUpdate Boolean @default(true)
supportPhone String?
supportEmail String?
supportWhatsapp String?
supportTelegram String?
supportInstagram String?
/// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
categoryImagesJson Json?
categoryImagesJson Json?
/// تعاریف دسته‌ها (کاربرد/فصل). اگر خالی باشد از مقادیر پیش‌فرض کد استفاده می‌شود.
categoryDefsJson Json?
updatedAt DateTime @updatedAt
categoryDefsJson Json?
updatedAt DateTime @updatedAt
}
/// بنرهای قابل مدیریت بخش‌های اپ کاربر
@@ -517,6 +520,8 @@ model AppBanner {
subtitle String?
ctaLabel String?
ctaLink String?
/// لینک کلیک روی تصویر بنر (مستقل از لینک دکمه)
bannerLink String?
imageUrl String?
displayOrder Int @default(0)
isActive Boolean @default(true)
@@ -627,32 +632,6 @@ model ProductFavorite {
@@index([customerId])
}
model Brand {
id String @id @default(cuid())
name String
slug String? @unique
description String?
isActive Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customers CustomerBrand[]
products Product[]
}
model CustomerBrand {
customerId String
brandId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
@@id([customerId, brandId])
@@index([brandId])
}
model CustomerLevelDef {
id String @id @default(cuid())
code String @unique
+28 -28
View File
File diff suppressed because one or more lines are too long
+367 -446
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -19,7 +19,6 @@ import { CartsModule } from './carts/carts.module';
import { NotificationsModule } from './notifications/notifications.module';
import { AgentModule } from './agent/agent.module';
import { CatalogRequestsModule } from './catalog-requests/catalog-requests.module';
import { BrandsModule } from './brands/brands.module';
import { LevelsModule } from './levels/levels.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { BannersModule } from './banners/banners.module';
@@ -46,7 +45,6 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo
NotificationsModule,
AgentModule,
CatalogRequestsModule,
BrandsModule,
LevelsModule,
SuggestionsModule,
BannersModule,
+27
View File
@@ -0,0 +1,27 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeBannerLink } from './banner-link.util';
describe('normalizeBannerLink', () => {
it('allows empty values', () => {
expect(normalizeBannerLink(null)).toBeNull();
expect(normalizeBannerLink('')).toBeNull();
expect(normalizeBannerLink(' ')).toBeNull();
});
it('accepts internal paths', () => {
expect(normalizeBannerLink('/categories')).toBe('/categories');
expect(normalizeBannerLink(' /products?q=1 ')).toBe('/products?q=1');
});
it('accepts http(s) URLs', () => {
expect(normalizeBannerLink('https://example.com/path')).toBe(
'https://example.com/path',
);
});
it('rejects invalid links', () => {
expect(() => normalizeBannerLink('ftp://x.com')).toThrow(BadRequestException);
expect(() => normalizeBannerLink('not a url')).toThrow(BadRequestException);
expect(() => normalizeBannerLink('/bad path')).toThrow(BadRequestException);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { BadRequestException } from '@nestjs/common';
/** مسیر داخلی (`/…`) یا URL با http/https */
export function normalizeBannerLink(value?: string | null): string | null {
if (value == null || value === '') return null;
const link = value.trim();
if (!link) return null;
if (link.startsWith('/')) {
if (!/^\/[^\s]*$/.test(link)) {
throw new BadRequestException('لینک بنر نامعتبر است');
}
return link;
}
try {
const url = new URL(link);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('invalid protocol');
}
return link;
} catch {
throw new BadRequestException('لینک بنر نامعتبر است');
}
}
+8
View File
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeBannerLink } from './banner-link.util';
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
const ALLOWED_SLOTS = new Set(['home_hero', 'products_hero', 'categories_hero']);
@@ -28,6 +29,7 @@ export class BannersService {
subtitle: dto.subtitle?.trim() || null,
ctaLabel: dto.ctaLabel?.trim() || null,
ctaLink: dto.ctaLink?.trim() || null,
bannerLink: normalizeBannerLink(dto.bannerLink),
imageUrl: this.normalizeImage(dto.imageUrl),
displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true,
@@ -52,6 +54,10 @@ export class BannersService {
dto.ctaLabel === undefined ? undefined : dto.ctaLabel?.trim() || null,
ctaLink:
dto.ctaLink === undefined ? undefined : dto.ctaLink?.trim() || null,
bannerLink:
dto.bannerLink === undefined
? undefined
: normalizeBannerLink(dto.bannerLink),
imageUrl:
dto.imageUrl === undefined
? undefined
@@ -109,6 +115,7 @@ export class BannersService {
subtitle: string | null;
ctaLabel: string | null;
ctaLink: string | null;
bannerLink: string | null;
imageUrl: string | null;
displayOrder: number;
isActive: boolean;
@@ -125,6 +132,7 @@ export class BannersService {
subtitle: row.subtitle ?? '',
ctaLabel: row.ctaLabel ?? '',
ctaLink: row.ctaLink ?? '',
bannerLink: row.bannerLink ?? '',
imageUrl: row.imageUrl ?? '',
displayOrder: row.displayOrder,
isActive: row.isActive,
+8
View File
@@ -29,6 +29,10 @@ export class CreateBannerDto {
@IsString()
ctaLink?: string | null;
@IsOptional()
@IsString()
bannerLink?: string | null;
@IsOptional()
@IsString()
imageUrl?: string | null;
@@ -79,6 +83,10 @@ export class UpdateBannerDto {
@IsString()
ctaLink?: string | null;
@IsOptional()
@IsString()
bannerLink?: string | null;
@IsOptional()
@IsString()
imageUrl?: string | null;
-49
View File
@@ -1,49 +0,0 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { BrandsService } from './brands.service';
import { AuthType } from '../common/decorators/auth.decorators';
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
import { IsArray, IsString } from 'class-validator';
class SetBrandsBody {
@IsArray()
@IsString({ each: true })
brandIds!: string[];
}
@Controller()
export class BrandsController {
constructor(private readonly brandsService: BrandsService) {}
@AuthType('admin')
@Get('admin/brands')
list(@Query('activeOnly') activeOnly?: string) {
return this.brandsService.list(activeOnly === 'true');
}
@AuthType('admin')
@Post('admin/brands')
create(@Body() dto: CreateBrandDto) {
return this.brandsService.create(dto);
}
@AuthType('admin')
@Patch('admin/brands/:id')
update(@Param('id') id: string, @Body() dto: UpdateBrandDto) {
return this.brandsService.update(id, dto);
}
@AuthType('admin')
@Get('admin/customers/:customerId/brands')
customerBrands(@Param('customerId') customerId: string) {
return this.brandsService.listCustomerBrands(customerId);
}
@AuthType('admin')
@Post('admin/customers/:customerId/brands')
setCustomerBrands(
@Param('customerId') customerId: string,
@Body() body: SetBrandsBody,
) {
return this.brandsService.setCustomerBrands(customerId, body.brandIds ?? []);
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { BrandsController } from './brands.controller';
import { BrandsService } from './brands.service';
@Module({
controllers: [BrandsController],
providers: [BrandsService],
exports: [BrandsService],
})
export class BrandsModule {}
-112
View File
@@ -1,112 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
@Injectable()
export class BrandsService {
constructor(private readonly prisma: PrismaService) {}
async list(activeOnly?: boolean) {
const rows = await this.prisma.brand.findMany({
where: activeOnly ? { isActive: true } : undefined,
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: { _count: { select: { products: true } } },
});
return rows.map((b) => ({
id: b.id,
name: b.name,
slug: b.slug ?? undefined,
description: b.description ?? undefined,
isActive: b.isActive,
displayOrder: b.displayOrder,
productsCount: b._count.products,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}));
}
async create(dto: CreateBrandDto) {
const brand = await this.prisma.brand.create({
data: {
name: dto.name.trim(),
slug: dto.slug?.trim() || null,
description: dto.description?.trim() || null,
displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true,
},
});
return this.map(brand);
}
async update(id: string, dto: UpdateBrandDto) {
await this.ensure(id);
const brand = await this.prisma.brand.update({
where: { id },
data: {
name: dto.name?.trim(),
slug:
dto.slug === undefined
? undefined
: dto.slug?.trim() || null,
description:
dto.description === undefined
? undefined
: dto.description?.trim() || null,
displayOrder: dto.displayOrder,
isActive: dto.isActive,
},
});
return this.map(brand);
}
async setCustomerBrands(customerId: string, brandIds: string[]) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
await this.prisma.customerBrand.deleteMany({ where: { customerId } });
if (brandIds.length) {
await this.prisma.customerBrand.createMany({
data: brandIds.map((brandId) => ({ customerId, brandId })),
skipDuplicates: true,
});
}
return this.listCustomerBrands(customerId);
}
async listCustomerBrands(customerId: string) {
const rows = await this.prisma.customerBrand.findMany({
where: { customerId },
include: { brand: true },
});
return rows.map((r) => this.map(r.brand));
}
private async ensure(id: string) {
const row = await this.prisma.brand.findUnique({ where: { id } });
if (!row) throw new NotFoundException('برند یافت نشد');
return row;
}
private map(b: {
id: string;
name: string;
slug: string | null;
description: string | null;
isActive: boolean;
displayOrder: number;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: b.id,
name: b.name,
slug: b.slug ?? undefined,
description: b.description ?? undefined,
isActive: b.isActive,
displayOrder: b.displayOrder,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
};
}
}
-62
View File
@@ -1,62 +0,0 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateBrandDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateBrandDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
slug?: string | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetCustomerBrandsDto {
brands!: string[];
}
+27 -14
View File
@@ -4,7 +4,12 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CashPaymentStatus, OrderStatus, SettlementType } from '@prisma/client';
import {
CashPaymentStatus,
CheckRegistrationMode,
OrderStatus,
SettlementType,
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
SubmitCashReceiptDto,
@@ -12,7 +17,10 @@ import {
} from './dto/cash-payment.dto';
import { joinFullName } from '../common/utils/name';
import { NotificationsService } from '../notifications/notifications.service';
import { requirePaymentRecipientCompany } from '../companies/default-company';
import {
usesCashReceiptPhase,
} from '../common/utils/settlement-type';
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
OrderStatus.cash_in_progress,
@@ -42,7 +50,7 @@ export class CashPaymentsService {
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.settlementType !== SettlementType.cash) {
if (!usesCashReceiptPhase(order.settlementType)) {
throw new BadRequestException('این سفارش تسویه نقدی نیست');
}
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
@@ -59,18 +67,11 @@ export class CashPaymentsService {
);
}
const companyIds = [
...new Set(
[
order.companyId,
...order.items.map((item) => item.companyId).filter(Boolean),
].filter(Boolean),
),
];
const paymentRecipient = await requirePaymentRecipientCompany(this.prisma);
const account = await this.prisma.bankAccount.findFirst({
where: {
id: dto.destinationAccountId,
companyId: { in: companyIds },
companyId: paymentRecipient.id,
isActive: true,
},
});
@@ -175,7 +176,10 @@ export class CashPaymentsService {
// رد رسید باید مشتری را به مرحله پرداخت برگرداند تا بتواند رسید تازه بفرستد.
let nextStatus: OrderStatus | null = null;
if (dto.status === CashPaymentStatus.approved) {
nextStatus = OrderStatus.cash_confirmed;
nextStatus =
receipt.order.settlementType === SettlementType.cash_check
? OrderStatus.check_in_progress
: OrderStatus.cash_confirmed;
} else if (dto.status === CashPaymentStatus.rejected) {
nextStatus = OrderStatus.awaiting_cash_payment;
} else if (dto.status === CashPaymentStatus.expired) {
@@ -185,7 +189,16 @@ export class CashPaymentsService {
if (nextStatus && nextStatus !== receipt.order.status) {
await tx.order.update({
where: { id: receipt.orderId },
data: { status: nextStatus },
data: {
status: nextStatus,
...(nextStatus === OrderStatus.check_in_progress &&
receipt.order.settlementType === SettlementType.cash_check
? {
checkMode:
receipt.order.checkMode ?? CheckRegistrationMode.full,
}
: {}),
},
});
await tx.orderStatusHistory.create({
data: {
@@ -0,0 +1,16 @@
import { BadRequestException } from '@nestjs/common';
import { dayRangeForRegisteredOn } from './catalog-request-date.util';
describe('dayRangeForRegisteredOn', () => {
it('builds start/end of civil day in Asia/Tehran', () => {
const { gte, lt } = dayRangeForRegisteredOn('2026-10-01');
expect(gte.toISOString()).toBe('2026-09-30T20:30:00.000Z');
expect(lt.toISOString()).toBe('2026-10-01T20:30:00.000Z');
});
it('rejects invalid input', () => {
expect(() => dayRangeForRegisteredOn('01-10-2026')).toThrow(
BadRequestException,
);
});
});
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
/** Iran standard time — same civil day as Jalali date picker (`YYYY-MM-DD`). */
const TEHRAN_DAY_START = /^(\d{4})-(\d{2})-(\d{2})$/;
/** Calendar day range in Asia/Tehran for `YYYY-MM-DD` (registration date filter). */
export function dayRangeForRegisteredOn(
registeredOn: string,
): { gte: Date; lt: Date } {
const m = registeredOn.trim().match(TEHRAN_DAY_START);
if (!m) {
throw new BadRequestException('فرمت تاریخ ثبت نامعتبر است');
}
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
if (mo < 1 || mo > 12 || d < 1 || d > 31) {
throw new BadRequestException('فرمت تاریخ ثبت نامعتبر است');
}
const gte = new Date(
`${m[1]}-${m[2]}-${m[3]}T00:00:00+03:30`,
);
const lt = new Date(gte.getTime() + 24 * 60 * 60 * 1000);
if (Number.isNaN(gte.getTime()) || Number.isNaN(lt.getTime())) {
throw new BadRequestException('فرمت تاریخ ثبت نامعتبر است');
}
return { gte, lt };
}
@@ -26,8 +26,11 @@ export class CatalogRequestsController {
@AuthType('admin')
@Roles('admin', 'sales', 'warehouse')
@Get('admin/catalog-requests')
adminList(@Query('status') status?: CatalogRequestStatus) {
return this.service.listAdmin(status);
adminList(
@Query('status') status?: CatalogRequestStatus,
@Query('registeredOn') registeredOn?: string,
) {
return this.service.listAdmin(status, registeredOn);
}
@AuthType('admin')
@@ -13,6 +13,7 @@ import {
UpdateCatalogRequestStatusDto,
UpdateCatalogItemHolderDto,
} from './dto/catalog-request.dto';
import { dayRangeForRegisteredOn } from './catalog-request-date.util';
const STATUS_FLOW: CatalogRequestStatus[] = [
'new',
@@ -27,9 +28,18 @@ const STATUS_FLOW: CatalogRequestStatus[] = [
export class CatalogRequestsService {
constructor(private readonly prisma: PrismaService) {}
async listAdmin(status?: CatalogRequestStatus) {
async listAdmin(status?: CatalogRequestStatus, registeredOn?: string) {
const createdAt =
registeredOn?.trim()
? dayRangeForRegisteredOn(registeredOn.trim())
: undefined;
const rows = await this.prisma.catalogRequest.findMany({
where: status ? { status } : undefined,
where: {
...(status ? { status } : {}),
...(createdAt
? { createdAt: { gte: createdAt.gte, lt: createdAt.lt } }
: {}),
},
orderBy: { createdAt: 'desc' },
include: {
customer: {
@@ -0,0 +1,30 @@
import {
filterSelectOptionsBySearch,
optionMatchesSelectSearch,
} from './select-option-search.util';
describe('select-option-search', () => {
const options = [
{ value: '1', label: 'پارچه مخمل' },
{ value: '2', label: 'كتان سفيد' },
{ value: '3', label: 'ژاکارد' },
];
it('matches Persian labels case-insensitively', () => {
expect(optionMatchesSelectSearch('پارچه مخمل', 'مخمل')).toBe(true);
expect(optionMatchesSelectSearch('پارچه مخمل', 'MVM')).toBe(false);
});
it('normalizes Arabic yeh/kaf and Persian digits', () => {
expect(optionMatchesSelectSearch('کتان سفید', 'سفيد')).toBe(true);
expect(optionMatchesSelectSearch('کتان سفید', 'كتان')).toBe(true);
expect(optionMatchesSelectSearch('کد ۱۲۳', '123')).toBe(true);
});
it('filters option list', () => {
expect(filterSelectOptionsBySearch(options, 'كت').map((o) => o.value)).toEqual([
'2',
]);
expect(filterSelectOptionsBySearch(options, '')).toHaveLength(3);
});
});
+28
View File
@@ -0,0 +1,28 @@
/** Shared with admin/user select components — keep in sync with selectOptionSearch.ts */
export function normalizeForSelectSearch(text: string): string {
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
const normalized = text.replace(/[۰-۹]/g, (digit) =>
String(persianDigits.indexOf(digit)),
)
return normalized
.toLowerCase()
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.normalize('NFKC')
.trim()
}
export function optionMatchesSelectSearch(label: string, query: string): boolean {
const q = normalizeForSelectSearch(query)
if (!q) return true
return normalizeForSelectSearch(label).includes(q)
}
export function filterSelectOptionsBySearch<T extends { label: string }>(
options: T[],
query: string,
): T[] {
const trimmed = query.trim()
if (!trimmed) return options
return options.filter((option) => optionMatchesSelectSearch(option.label, trimmed))
}
+1
View File
@@ -25,6 +25,7 @@ export const ORDER_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
],
awaiting_cash_confirmation: [
OrderStatus.cash_confirmed,
OrderStatus.check_in_progress,
OrderStatus.awaiting_cash_payment,
],
cash_confirmed: [OrderStatus.final_approved],
+19
View File
@@ -0,0 +1,19 @@
import { SettlementType } from '@prisma/client';
/** تسویه‌هایی که ابتدا (یا تنها) مرحله واریز نقدی / رسید دارند. */
export function usesCashReceiptPhase(
type: SettlementType | null | undefined,
): boolean {
return (
type === SettlementType.cash || type === SettlementType.cash_check
);
}
/** تسویه‌هایی که ثبت و بررسی چک دارند (خالص چکی یا نقدی-چکی پس از تأیید نقدی). */
export function usesCheckSettlement(
type: SettlementType | null | undefined,
): boolean {
return (
type === SettlementType.check || type === SettlementType.cash_check
);
}
+6
View File
@@ -35,6 +35,12 @@ export class CompaniesController {
return this.companiesService.findAll(true);
}
@Public()
@Get('payment/bank-accounts')
listPaymentBankAccounts() {
return this.companiesService.listPaymentBankAccounts();
}
@AuthType('any')
@Get(':id')
findOne(@Param('id') id: string) {
+27 -2
View File
@@ -1,8 +1,13 @@
import {
BadRequestException,
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import {
isPaymentRecipientCompany,
requirePaymentRecipientCompany,
} from './default-company';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { resolveMaxMeterage } from '../common/utils/meterage';
@@ -146,8 +151,21 @@ export class CompaniesService {
return this.mapCompany(company);
}
/** حساب‌های واریز پرداخت مشتری — همیشه نادمی تکس */
async listPaymentBankAccounts() {
const recipient = await requirePaymentRecipientCompany(this.prisma);
return this.listBankAccountsForCompany(recipient.id);
}
/**
* لیست حساب برای اپ/ادمین: پرداخت همیشه به نادمی تکس است، نه شرکت مالک محصول.
*/
async listBankAccounts(companyId: string) {
await this.ensureCompany(companyId);
return this.listPaymentBankAccounts();
}
private async listBankAccountsForCompany(companyId: string) {
const accounts = await this.prisma.bankAccount.findMany({
where: { companyId },
orderBy: { displayOrder: 'asc' },
@@ -156,10 +174,16 @@ export class CompaniesService {
}
async createBankAccount(companyId: string, dto: CreateBankAccountDto) {
await this.ensureCompany(companyId);
const company = await this.ensureCompany(companyId);
if (!isPaymentRecipientCompany(company)) {
throw new BadRequestException(
'حساب بانکی فقط برای شرکت نادمی تکس (دریافت‌کننده پرداخت) ثبت می‌شود.',
);
}
const recipient = await requirePaymentRecipientCompany(this.prisma);
const account = await this.prisma.bankAccount.create({
data: {
companyId,
companyId: recipient.id,
bankName: dto.bankName.trim(),
accountHolder: dto.accountHolder.trim(),
accountNumber: dto.accountNumber.trim(),
@@ -234,6 +258,7 @@ export class CompaniesService {
id: company.id,
name: company.name,
slug: company.slug,
isPaymentRecipient: isPaymentRecipientCompany(company),
description: company.description ?? '',
logoUrl: company.logoUrl,
logo: company.logoUrl ?? undefined,
+15
View File
@@ -0,0 +1,15 @@
import {
PAYMENT_RECIPIENT_COMPANY_SLUG,
isPaymentRecipientCompany,
} from './default-company';
describe('default-company', () => {
it('uses stable slug for payment recipient', () => {
expect(PAYMENT_RECIPIENT_COMPANY_SLUG).toBe('nademitex');
});
it('detects payment recipient by slug', () => {
expect(isPaymentRecipientCompany({ slug: 'nademitex' })).toBe(true);
expect(isPaymentRecipientCompany({ slug: 'par' })).toBe(false);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { Company, PrismaClient } from '@prisma/client';
/** Slug ثابت شرکت اصلی — منبع حقیقت برای دریافت پرداخت و پیش‌فرض محصول */
export const PAYMENT_RECIPIENT_COMPANY_SLUG = 'nademitex';
type PrismaLike = Pick<PrismaClient, 'company'>;
export async function findPaymentRecipientCompany(
prisma: PrismaLike,
): Promise<Company | null> {
return prisma.company.findFirst({
where: { slug: PAYMENT_RECIPIENT_COMPANY_SLUG, isActive: true },
});
}
export async function requirePaymentRecipientCompany(
prisma: PrismaLike,
): Promise<Company> {
const company = await findPaymentRecipientCompany(prisma);
if (!company) {
throw new NotFoundException(
'شرکت اصلی (نادمی تکس) در سیستم تعریف نشده است. لطفاً با پشتیبانی تماس بگیرید.',
);
}
return company;
}
/** شرکت مرتبط با محصول؛ در صورت نبود مقدار معتبر، نادمی تکس */
export async function resolveProductCompanyId(
prisma: PrismaLike,
companyId?: string | null,
): Promise<string> {
const trimmed = companyId?.trim();
if (trimmed) {
const row = await prisma.company.findUnique({ where: { id: trimmed } });
if (!row) {
throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
if (!row.isActive) {
throw new BadRequestException('شرکت انتخاب‌شده غیرفعال است');
}
return row.id;
}
return (await requirePaymentRecipientCompany(prisma)).id;
}
export function isPaymentRecipientCompany(company: {
slug: string;
}): boolean {
return company.slug === PAYMENT_RECIPIENT_COMPANY_SLUG;
}
+22 -11
View File
@@ -23,6 +23,10 @@ import {
} from './dto/order.dto';
import { generateOrderNumber } from '../common/utils/order-number';
import { calculateCheckRass } from '../common/utils/check-rass';
import {
usesCashReceiptPhase,
usesCheckSettlement,
} from '../common/utils/settlement-type';
import { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry';
import {
resolveAdminStatusFilter,
@@ -217,25 +221,34 @@ export class OrdersService {
);
}
const isCash = dto.settlementType === SettlementType.cash;
const nextStatus = isCash
const cashFirst = usesCashReceiptPhase(dto.settlementType);
const checkOnly =
dto.settlementType === SettlementType.check;
const nextStatus = cashFirst
? OrderStatus.awaiting_cash_payment
: OrderStatus.check_in_progress;
assertTransition(order.status, nextStatus);
const cashCompanies = isCash
const cashCompanies = cashFirst
? await this.companiesForOrder(order)
: [];
const settlementNote =
dto.settlementType === SettlementType.cash
? 'مشتری تسویه نقدی را انتخاب کرد'
: dto.settlementType === SettlementType.cash_check
? 'مشتری تسویه نقدی-چکی را انتخاب کرد'
: 'مشتری تسویه چکی را انتخاب کرد';
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id },
data: {
settlementType: dto.settlementType,
checkMode: isCash
? null
: (dto.checkMode ?? CheckRegistrationMode.full),
cashInvoiceExpiresAt: isCash
checkMode: checkOnly
? (dto.checkMode ?? CheckRegistrationMode.full)
: null,
cashInvoiceExpiresAt: cashFirst
? earliestCashInvoiceExpiry(cashCompanies)
: null,
cashReminderSentAt: null,
@@ -248,9 +261,7 @@ export class OrdersService {
fromStatus: order.status,
toStatus: nextStatus,
changedBy: 'customer',
note: isCash
? 'مشتری تسویه نقدی را انتخاب کرد'
: 'مشتری تسویه چکی را انتخاب کرد',
note: settlementNote,
},
});
});
@@ -575,7 +586,7 @@ export class OrdersService {
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.settlementType !== SettlementType.check) {
if (!usesCheckSettlement(order.settlementType)) {
throw new BadRequestException('این سفارش تسویه چکی نیست');
}
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
+52 -1
View File
@@ -1,9 +1,16 @@
import { ValidationPipe } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
colorVariantImageLists,
parseColorEntries,
parseGeneralImageUrls,
} from './color-entries';
import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
import {
ProductColorJsonItemDto,
serializeColorsJsonForDb,
} from './dto/product-color-json.dto';
import { UpdateProductDto } from './dto/product.dto';
describe('product image separation', () => {
it('keeps general images separate from color variant urls', () => {
@@ -66,6 +73,50 @@ describe('product image separation', () => {
]);
});
it('accepts colorsJson through Nest ValidationPipe (same as PATCH /products)', async () => {
const pipe = new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true },
});
const body = {
name: 'تست',
color: 'قرمز',
colorsJson: [
{
name: 'قرمز شرابی',
imageUrl: 'data:image/jpeg;base64,abc',
imageUrls: ['data:image/jpeg;base64,abc'],
},
],
};
const dto = await pipe.transform(body, {
type: 'body',
metatype: UpdateProductDto,
});
expect(dto.colorsJson?.[0]).toBeInstanceOf(ProductColorJsonItemDto);
});
it('accepts colorsJson name/imageUrl/imageUrls under whitelist validation', async () => {
const dto = plainToInstance(UpdateProductDto, {
name: 'تست',
colorsJson: [
{
name: 'قرمز شرابی',
imageUrl: 'data:image/jpeg;base64,abc',
imageUrls: ['data:image/jpeg;base64,abc'],
},
],
});
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
expect(errors).toEqual([]);
expect(dto.colorsJson?.[0]).toBeInstanceOf(ProductColorJsonItemDto);
});
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([
+59 -7
View File
@@ -1,4 +1,54 @@
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
import {
IsArray,
IsOptional,
IsString,
MinLength,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
type ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'isColorsJson', async: false })
export class IsColorsJsonConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (!Array.isArray(value)) return false;
for (const item of value) {
if (!(item instanceof ProductColorJsonItemDto)) return false;
if (!item.name?.trim()) return false;
if (
item.imageUrl !== undefined &&
item.imageUrl !== null &&
typeof item.imageUrl !== 'string'
) {
return false;
}
if (item.imageUrls !== undefined && item.imageUrls !== null) {
if (!Array.isArray(item.imageUrls)) return false;
if (!item.imageUrls.every((u) => typeof u === 'string')) return false;
}
}
return true;
}
defaultMessage(): string {
return 'فرمت colorsJson نامعتبر است';
}
}
/** اعتبارسنجی colorsJson بعد از transform — بدون ValidateNested (سازگار با whitelist) */
export function IsColorsJson(validationOptions?: ValidationOptions) {
return function register(object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsColorsJsonConstraint,
});
};
}
export class ProductColorJsonItemDto {
@IsString()
@@ -38,7 +88,7 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
for (const item of value) {
if (typeof item === 'string') {
const name = item.trim();
if (name) out.push({ name });
if (name) out.push(Object.assign(new ProductColorJsonItemDto(), { name }));
continue;
}
if (item && typeof item === 'object') {
@@ -54,11 +104,13 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
? raw.imageUrl.trim()
: undefined;
out.push({
name,
imageUrl,
imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined,
});
out.push(
Object.assign(new ProductColorJsonItemDto(), {
name,
imageUrl,
imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined,
}),
);
}
}
return out;
+17 -10
View File
@@ -7,21 +7,19 @@ import {
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsColorsJson,
ProductColorJsonItemDto,
transformColorsJson,
} from './product-color-json.dto';
export class CreateProductDto {
@IsString()
companyId!: string;
/** خالی = شرکت پیش‌فرض (نادمی تکس) */
@IsOptional()
@IsString()
brandId?: string | null;
companyId?: string;
@IsString()
@MinLength(1)
@@ -44,6 +42,11 @@ export class CreateProductDto {
@IsString()
description?: string;
/** مشخصات آزاد — نمایش در specs-extra اپ */
@IsOptional()
@IsString()
specs?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@@ -139,8 +142,7 @@ export class CreateProductDto {
@IsOptional()
@Transform(({ value }) => transformColorsJson(value))
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductColorJsonItemDto)
@IsColorsJson()
colorsJson?: ProductColorJsonItemDto[];
@IsOptional()
@@ -149,18 +151,23 @@ export class CreateProductDto {
imageUrlsJson?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
tagsJson?: string[];
}
/** همهٔ فیلدهای Create به‌جز companyId — شامل brandId برای PATCH ادمین */
/** همهٔ فیلدهای Create به‌جز companyId — companyId در PATCH برای تغییر شرکت محصول */
export class UpdateProductDto extends PartialType(
OmitType(CreateProductDto, ['companyId'] as const),
) {
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@Transform(({ value }) => transformColorsJson(value))
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductColorJsonItemDto)
@IsColorsJson()
colorsJson?: ProductColorJsonItemDto[];
}
+20
View File
@@ -0,0 +1,20 @@
import { isProductUnavailableForApp } from './product-availability';
describe('isProductUnavailableForApp', () => {
it('treats admin-inactive products as unavailable', () => {
expect(isProductUnavailableForApp({ isActive: false, stock: 10 })).toBe(
true,
);
});
it('keeps active products available when stock is unset or positive', () => {
expect(isProductUnavailableForApp({ isActive: true })).toBe(false);
expect(isProductUnavailableForApp({ isActive: true, stock: 4 })).toBe(
false,
);
});
it('treats zero stock as unavailable', () => {
expect(isProductUnavailableForApp({ isActive: true, stock: 0 })).toBe(true);
});
});
+8
View File
@@ -0,0 +1,8 @@
/** App lists keep inactive products; they are shown as unavailable via `isActive`. */
export function isProductUnavailableForApp(product: {
isActive: boolean;
stock?: number | null;
}): boolean {
if (!product.isActive) return true;
return product.stock != null && product.stock <= 0;
}
+6
View File
@@ -109,4 +109,10 @@ export class ProductsController {
setActive(@Param('id') id: string, @Body() dto: SetProductActiveDto) {
return this.productsService.setActive(id, dto.isActive);
}
@AuthType('admin')
@Delete(':id')
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
+48 -22
View File
@@ -1,5 +1,4 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
@@ -9,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
import { resolveProductCompanyId } from '../companies/default-company';
import {
colorVariantImageLists,
parseColorEntries,
@@ -17,7 +17,6 @@ import {
const productInclude = {
company: { select: { name: true } },
brand: { select: { id: true, name: true } },
};
@Injectable()
@@ -118,17 +117,20 @@ export class ProductsService {
async create(dto: CreateProductDto, preservedColorsJson?: unknown) {
const code = (dto.code || dto.productCode || '').trim();
if (!code) throw new ConflictException('کد محصول الزامی است');
const brandId = await this.resolveBrandId(dto.brandId ?? null);
const companyId = await resolveProductCompanyId(
this.prisma,
dto.companyId,
);
try {
const product = await this.prisma.product.create({
data: {
companyId: dto.companyId,
brandId,
companyId,
name: dto.name.trim(),
code,
color: dto.color.trim(),
description: dto.description?.trim() || null,
specs: dto.specs?.trim() || null,
minMeterage: dto.minMeterage ?? 30,
orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10,
basePrice: this.pickNumber(dto.basePrice, dto.price),
@@ -177,13 +179,18 @@ export class ProductsService {
: dto.productCode !== undefined
? dto.productCode.trim()
: undefined;
const brandId = await this.resolveBrandId(dto.brandId);
try {
const product = await this.prisma.product.update({
where: { id },
data: {
...(brandId !== undefined ? { brandId } : {}),
...(dto.companyId !== undefined
? {
companyId: await resolveProductCompanyId(
this.prisma,
dto.companyId,
),
}
: {}),
name: dto.name?.trim(),
code,
color: dto.color?.trim(),
@@ -191,6 +198,8 @@ export class ProductsService {
dto.description === undefined
? undefined
: dto.description?.trim() || null,
specs:
dto.specs === undefined ? undefined : dto.specs?.trim() || null,
minMeterage: dto.minMeterage,
orderMultiplier:
dto.orderMultiplier !== undefined
@@ -268,6 +277,35 @@ export class ProductsService {
return this.map(product);
}
async remove(id: string) {
await this.ensure(id);
const catalogRequestCount = await this.prisma.catalogRequestItem.count({
where: { productId: id },
});
if (catalogRequestCount > 0) {
throw new ConflictException(
'این محصول در درخواست کالیته ثبت شده و قابل حذف نیست.',
);
}
try {
await this.prisma.$transaction([
this.prisma.cartItem.deleteMany({ where: { productId: id } }),
this.prisma.product.delete({ where: { id } }),
]);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2003'
) {
throw new ConflictException(
'این محصول به داده‌های دیگر وابسته است و قابل حذف نیست.',
);
}
throw e;
}
return { ok: true };
}
async listFavoriteIds(customerId: string) {
const rows = await this.prisma.productFavorite.findMany({
where: { customerId },
@@ -316,17 +354,6 @@ export class ProductsService {
return product;
}
private async resolveBrandId(
brandId?: string | null,
): Promise<string | null | undefined> {
if (brandId === undefined) return undefined;
const id = brandId?.trim() || null;
if (!id) return null;
const brand = await this.prisma.brand.findUnique({ where: { id } });
if (!brand) throw new BadRequestException('برند یافت نشد');
return id;
}
private map(
product: {
id: string;
@@ -335,6 +362,7 @@ export class ProductsService {
code: string;
color: string;
description: string | null;
specs?: string | null;
minMeterage: number;
orderMultiplier: number;
basePrice: number | null;
@@ -343,7 +371,6 @@ export class ProductsService {
createdAt: Date;
updatedAt: Date;
company: { name: string };
brand?: { id: string; name: string } | null;
usageCategory?: string;
season?: string;
widthCm?: number | null;
@@ -381,13 +408,12 @@ export class ProductsService {
catalogCode: product.code,
/** @deprecated use `name` */
catalogName: product.name,
brandId: product.brand?.id ?? null,
brandName: product.brand?.name ?? null,
name: product.name,
code: product.code,
productCode: product.code,
color: product.color,
description: product.description ?? undefined,
specs: product.specs?.trim() ? product.specs : undefined,
minMeterage: product.minMeterage,
orderMultiplier: product.orderMultiplier,
orderStep: product.orderMultiplier,
+16
View File
@@ -22,6 +22,10 @@ export class UpdateSettingsDto {
@IsBoolean()
defaultSettlementCash?: boolean;
@IsOptional()
@IsBoolean()
defaultSettlementCashCheck?: boolean;
@IsOptional()
@IsBoolean()
smsOrderRegistered?: boolean;
@@ -70,6 +74,18 @@ export class UpdateSettingsDto {
@IsString()
supportEmail?: string | null;
@IsOptional()
@IsString()
supportWhatsapp?: string | null;
@IsOptional()
@IsString()
supportTelegram?: string | null;
@IsOptional()
@IsString()
supportInstagram?: string | null;
@IsOptional()
@IsObject()
categoryImagesJson?: Record<string, string | null>;
+27
View File
@@ -22,6 +22,12 @@ export class SettingsService {
return {
supportPhone: settings.supportPhone,
supportEmail: settings.supportEmail,
supportWhatsapp: settings.supportWhatsapp,
supportTelegram: settings.supportTelegram,
supportInstagram: settings.supportInstagram,
defaultSettlementCash: settings.defaultSettlementCash,
defaultSettlementCheck: settings.defaultSettlementCheck,
defaultSettlementCashCheck: settings.defaultSettlementCashCheck ?? false,
};
}
@@ -43,6 +49,7 @@ export class SettingsService {
cashInvoiceValidityHours: dto.cashInvoiceValidityHours,
defaultSettlementCheck: dto.defaultSettlementCheck,
defaultSettlementCash: dto.defaultSettlementCash,
defaultSettlementCashCheck: dto.defaultSettlementCashCheck,
minOrderRollsPerProduct: dto.minOrderRollsPerProduct,
showCatalogPublicPrice: dto.showCatalogPublicPrice,
allowAgentPricing: dto.allowAgentPricing,
@@ -63,6 +70,18 @@ export class SettingsService {
dto.supportEmail === undefined
? undefined
: dto.supportEmail?.trim() || null,
supportWhatsapp:
dto.supportWhatsapp === undefined
? undefined
: dto.supportWhatsapp?.trim() || null,
supportTelegram:
dto.supportTelegram === undefined
? undefined
: dto.supportTelegram?.trim() || null,
supportInstagram:
dto.supportInstagram === undefined
? undefined
: dto.supportInstagram?.trim() || null,
categoryDefsJson: nextDefs,
categoryImagesJson:
dto.categoryImagesJson === undefined
@@ -87,6 +106,7 @@ export class SettingsService {
cashInvoiceValidityHours: number;
defaultSettlementCheck: boolean;
defaultSettlementCash: boolean;
defaultSettlementCashCheck?: boolean;
minOrderRollsPerProduct?: number;
showCatalogPublicPrice?: boolean;
allowAgentPricing?: boolean;
@@ -98,6 +118,9 @@ export class SettingsService {
smsShippingUpdate: boolean;
supportPhone: string | null;
supportEmail: string | null;
supportWhatsapp?: string | null;
supportTelegram?: string | null;
supportInstagram?: string | null;
categoryImagesJson?: unknown;
categoryDefsJson?: unknown;
updatedAt: Date;
@@ -109,6 +132,7 @@ export class SettingsService {
cashInvoiceValidityHours: settings.cashInvoiceValidityHours,
defaultSettlementCheck: settings.defaultSettlementCheck,
defaultSettlementCash: settings.defaultSettlementCash,
defaultSettlementCashCheck: settings.defaultSettlementCashCheck ?? false,
minOrderRollsPerProduct: settings.minOrderRollsPerProduct ?? 1,
showCatalogPublicPrice: settings.showCatalogPublicPrice ?? false,
allowAgentPricing: settings.allowAgentPricing ?? false,
@@ -120,6 +144,9 @@ export class SettingsService {
smsShippingUpdate: settings.smsShippingUpdate,
supportPhone: settings.supportPhone,
supportEmail: settings.supportEmail,
supportWhatsapp: settings.supportWhatsapp ?? null,
supportTelegram: settings.supportTelegram ?? null,
supportInstagram: settings.supportInstagram ?? null,
categoryImagesJson: asImageMap(settings.categoryImagesJson),
categoryDefsJson: defs,
updatedAt: settings.updatedAt.toISOString(),
+3
View File
@@ -15,6 +15,7 @@ const PRODUCT_INCLUDE = {
color: true,
subtitle: true,
isNew: true,
isActive: true,
colorsJson: true,
imageUrlsJson: true,
widthCm: true,
@@ -142,6 +143,7 @@ export class SuggestionsService {
color: string;
subtitle: string | null;
isNew: boolean;
isActive: boolean;
colorsJson: unknown;
imageUrlsJson: unknown;
widthCm: number | null;
@@ -180,6 +182,7 @@ export class SuggestionsService {
widthCm: r.product.widthCm ?? undefined,
composition: r.product.composition?.trim() || undefined,
isNew: r.product.isNew,
productIsActive: r.product.isActive,
note: r.note ?? undefined,
isActive: r.isActive,
createdAt: r.createdAt.toISOString(),