diff --git a/prisma/migrations/20261012120000_special_order_details/migration.sql b/prisma/migrations/20261012120000_special_order_details/migration.sql new file mode 100644 index 0000000..1c34ea6 --- /dev/null +++ b/prisma/migrations/20261012120000_special_order_details/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "SpecialOrder" ADD COLUMN "neededDate" TIMESTAMP(3), +ADD COLUMN "receiveTime" TEXT NOT NULL DEFAULT '', +ADD COLUMN "servings" INTEGER, +ADD COLUMN "cakeFlavor" TEXT NOT NULL DEFAULT '', +ADD COLUMN "filling" TEXT NOT NULL DEFAULT '', +ADD COLUMN "cakeText" TEXT NOT NULL DEFAULT '', +ADD COLUMN "decorations" TEXT NOT NULL DEFAULT '', +ADD COLUMN "deliveryType" "DeliveryType", +ADD COLUMN "branchId" TEXT, +ADD COLUMN "branchName" TEXT NOT NULL DEFAULT ''; + +-- CreateIndex +CREATE INDEX "SpecialOrder_branchId_idx" ON "SpecialOrder"("branchId"); + +-- AddForeignKey +ALTER TABLE "SpecialOrder" ADD CONSTRAINT "SpecialOrder_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20261012130000_special_order_flavor_options/migration.sql b/prisma/migrations/20261012130000_special_order_flavor_options/migration.sql new file mode 100644 index 0000000..a3e8898 --- /dev/null +++ b/prisma/migrations/20261012130000_special_order_flavor_options/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "SpecialOrder" ADD COLUMN "baloutSuggestedCombo" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "coating" TEXT NOT NULL DEFAULT '', +ADD COLUMN "designStyle" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/migrations/20261012140000_special_order_number/migration.sql b/prisma/migrations/20261012140000_special_order_number/migration.sql new file mode 100644 index 0000000..2cf6157 --- /dev/null +++ b/prisma/migrations/20261012140000_special_order_number/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +CREATE SEQUENCE "SpecialOrder_number_seq"; + +ALTER TABLE "SpecialOrder" ADD COLUMN "number" INTEGER NOT NULL DEFAULT nextval('"SpecialOrder_number_seq"'); + +ALTER SEQUENCE "SpecialOrder_number_seq" OWNED BY "SpecialOrder"."number"; + +-- CreateIndex +CREATE UNIQUE INDEX "SpecialOrder_number_key" ON "SpecialOrder"("number"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62cb22f..bac9d27 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -213,15 +213,16 @@ model ShippingException { } model Branch { - id String @id @default(cuid()) - name String - district String - address String - landline String @default("") - cellNumber String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - orders Order[] + id String @id @default(cuid()) + name String + district String + address String + landline String @default("") + cellNumber String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + orders Order[] + specialOrders SpecialOrder[] } model Order { @@ -313,19 +314,36 @@ enum SpecialOrderCategory { /// Custom request form (no payment / product link) — shown to admins. model SpecialOrder { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - category SpecialOrderCategory - name String - description String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - images SpecialOrderImage[] + id String @id @default(cuid()) + number Int @unique @default(autoincrement()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + category SpecialOrderCategory + /// مناسبت سفارش + name String + description String @default("") + neededDate DateTime? + receiveTime String @default("") + servings Int? + cakeFlavor String @default("") + filling String @default("") + baloutSuggestedCombo Boolean @default(false) + coating String @default("") + designStyle String @default("") + cakeText String @default("") + decorations String @default("") + deliveryType DeliveryType? + branchId String? + branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull) + branchName String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + images SpecialOrderImage[] @@index([userId]) @@index([category]) @@index([createdAt]) + @@index([branchId]) } model SpecialOrderImage { diff --git a/src/special-orders/dto/create-special-order.dto.ts b/src/special-orders/dto/create-special-order.dto.ts index 6c18adf..acb3cbf 100644 --- a/src/special-orders/dto/create-special-order.dto.ts +++ b/src/special-orders/dto/create-special-order.dto.ts @@ -1,6 +1,19 @@ -import { IsArray, IsEnum, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + Max, + Min, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; -import { SpecialOrderCategory } from '@prisma/client'; +import { DeliveryType, SpecialOrderCategory } from '@prisma/client'; export class SpecialOrderImageDto { @IsString() @@ -12,44 +25,82 @@ export class SpecialOrderImageDto { storageKey!: string; } -export class CreateSpecialOrderDto { +class SpecialOrderDetailsDto { + @IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' }) + category!: SpecialOrderCategory; + + @IsString() + @MinLength(1, { message: 'مناسبت سفارش الزامی است' }) + name!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsISO8601({}, { message: 'تاریخ موردنیاز نامعتبر است' }) + neededDate?: string; + + @IsOptional() + @IsString() + receiveTime?: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'تعداد نفرات باید عدد باشد' }) + @Min(1, { message: 'تعداد نفرات حداقل ۱ است' }) + @Max(500, { message: 'تعداد نفرات بیش از حد مجاز است' }) + servings?: number; + + @IsOptional() + @IsString() + cakeFlavor?: string; + + @IsOptional() + @IsString() + filling?: string; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + baloutSuggestedCombo?: boolean; + + @IsOptional() + @IsString() + coating?: string; + + @IsOptional() + @IsString() + designStyle?: string; + + @IsOptional() + @IsString() + cakeText?: string; + + @IsOptional() + @IsString() + decorations?: string; + + @IsOptional() + @IsEnum(DeliveryType, { message: 'نوع دریافت نامعتبر است' }) + deliveryType?: DeliveryType; + + @ValidateIf((dto: SpecialOrderDetailsDto) => dto.deliveryType === DeliveryType.pickup) + @IsString({ message: 'انتخاب شعبه الزامی است' }) + @MinLength(1, { message: 'انتخاب شعبه الزامی است' }) + branchId?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SpecialOrderImageDto) + images?: SpecialOrderImageDto[]; +} + +export class CreateSpecialOrderDto extends SpecialOrderDetailsDto { @IsString() @MinLength(1) userId!: string; - - @IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' }) - category!: SpecialOrderCategory; - - @IsString() - @MinLength(1, { message: 'نام الزامی است' }) - name!: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => SpecialOrderImageDto) - images?: SpecialOrderImageDto[]; } -export class CreateMySpecialOrderDto { - @IsEnum(SpecialOrderCategory, { message: 'دسته‌بندی نامعتبر است' }) - category!: SpecialOrderCategory; - - @IsString() - @MinLength(1, { message: 'نام الزامی است' }) - name!: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => SpecialOrderImageDto) - images?: SpecialOrderImageDto[]; -} +export class CreateMySpecialOrderDto extends SpecialOrderDetailsDto {} diff --git a/src/special-orders/dto/update-special-order.dto.ts b/src/special-orders/dto/update-special-order.dto.ts index 8169ff2..79c5658 100644 --- a/src/special-orders/dto/update-special-order.dto.ts +++ b/src/special-orders/dto/update-special-order.dto.ts @@ -1,6 +1,18 @@ -import { IsArray, IsEnum, IsOptional, IsString, MinLength, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + Max, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; -import { SpecialOrderCategory } from '@prisma/client'; +import { DeliveryType, SpecialOrderCategory } from '@prisma/client'; import { SpecialOrderImageDto } from './create-special-order.dto'; export class UpdateSpecialOrderDto { @@ -15,13 +27,65 @@ export class UpdateSpecialOrderDto { @IsOptional() @IsString() - @MinLength(1, { message: 'نام الزامی است' }) + @MinLength(1, { message: 'مناسبت سفارش الزامی است' }) name?: string; @IsOptional() @IsString() description?: string; + @IsOptional() + @IsISO8601({}, { message: 'تاریخ موردنیاز نامعتبر است' }) + neededDate?: string | null; + + @IsOptional() + @IsString() + receiveTime?: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'تعداد نفرات باید عدد باشد' }) + @Min(1, { message: 'تعداد نفرات حداقل ۱ است' }) + @Max(500, { message: 'تعداد نفرات بیش از حد مجاز است' }) + servings?: number | null; + + @IsOptional() + @IsString() + cakeFlavor?: string; + + @IsOptional() + @IsString() + filling?: string; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + baloutSuggestedCombo?: boolean; + + @IsOptional() + @IsString() + coating?: string; + + @IsOptional() + @IsString() + designStyle?: string; + + @IsOptional() + @IsString() + cakeText?: string; + + @IsOptional() + @IsString() + decorations?: string; + + @IsOptional() + @IsEnum(DeliveryType, { message: 'نوع دریافت نامعتبر است' }) + deliveryType?: DeliveryType | null; + + @IsOptional() + @IsString() + branchId?: string | null; + @IsOptional() @IsArray() @ValidateNested({ each: true }) diff --git a/src/special-orders/special-orders.service.ts b/src/special-orders/special-orders.service.ts index c478886..54b68cf 100644 --- a/src/special-orders/special-orders.service.ts +++ b/src/special-orders/special-orders.service.ts @@ -1,10 +1,16 @@ import { + BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; -import { Prisma, SpecialOrderCategory, UserRole } from '@prisma/client'; +import { + DeliveryType, + Prisma, + SpecialOrderCategory, + UserRole, +} from '@prisma/client'; import { displayName } from '../auth/auth.types'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../sms/sms.service'; @@ -28,20 +34,28 @@ const specialOrderInclude = { role: true, }, }, + branch: { + select: { + id: true, + name: true, + district: true, + address: true, + }, + }, images: { orderBy: { sortOrder: 'asc' as const }, }, } satisfies Prisma.SpecialOrderInclude; +const DELIVERY_LABEL: Record = { + pickup: 'دریافت حضوری', + shipping: 'ارسال با پیک', +}; + type SpecialOrderRow = Prisma.SpecialOrderGetPayload<{ include: typeof specialOrderInclude; }>; -const CATEGORY_LABEL: Record = { - specialCake: 'کیک ویژه', - specialPastry: 'شیرینی ویژه', -}; - @Injectable() export class SpecialOrdersService { private readonly logger = new Logger(SpecialOrdersService.name); @@ -65,6 +79,17 @@ export class SpecialOrdersService { OR: [ { name: { contains: q, mode: 'insensitive' as const } }, { description: { contains: q, mode: 'insensitive' as const } }, + { cakeFlavor: { contains: q, mode: 'insensitive' as const } }, + { filling: { contains: q, mode: 'insensitive' as const } }, + { coating: { contains: q, mode: 'insensitive' as const } }, + { designStyle: { contains: q, mode: 'insensitive' as const } }, + { cakeText: { contains: q, mode: 'insensitive' as const } }, + { decorations: { contains: q, mode: 'insensitive' as const } }, + { branchName: { contains: q, mode: 'insensitive' as const } }, + ...(Number.isFinite(Number(q.replace(/\D/g, ''))) && + q.replace(/\D/g, '') + ? [{ number: Number(q.replace(/\D/g, '')) }] + : []), { user: { OR: [ @@ -123,12 +148,26 @@ export class SpecialOrdersService { async create(dto: CreateSpecialOrderDto) { await this.ensureUser(dto.userId); + const details = await this.resolveDetails(dto); const row = await this.prisma.specialOrder.create({ data: { userId: dto.userId, category: dto.category, name: dto.name.trim(), - description: dto.description?.trim() ?? '', + description: details.description ?? '', + neededDate: details.neededDate ?? null, + receiveTime: details.receiveTime ?? '', + servings: details.servings ?? null, + cakeFlavor: details.cakeFlavor ?? '', + filling: details.filling ?? '', + baloutSuggestedCombo: details.baloutSuggestedCombo ?? false, + coating: details.coating ?? '', + designStyle: details.designStyle ?? '', + cakeText: details.cakeText ?? '', + decorations: details.decorations ?? '', + deliveryType: details.deliveryType ?? null, + branchId: details.branchId ?? null, + branchName: details.branchName ?? '', images: { create: this.imageCreates(dto.images), }, @@ -162,10 +201,7 @@ export class SpecialOrdersService { return this.create({ userId, - category: dto.category, - name: dto.name, - description: dto.description, - images: dto.images, + ...dto, }); } @@ -173,6 +209,8 @@ export class SpecialOrdersService { await this.ensureExists(id); if (dto.userId) await this.ensureUser(dto.userId); + const details = await this.resolveDetails(dto, { partial: true }); + const row = await this.prisma.$transaction(async (tx) => { if (dto.images !== undefined) { await tx.specialOrderImage.deleteMany({ @@ -184,11 +222,7 @@ export class SpecialOrdersService { where: { id }, data: { ...(dto.userId !== undefined ? { userId: dto.userId } : {}), - ...(dto.category !== undefined ? { category: dto.category } : {}), - ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), - ...(dto.description !== undefined - ? { description: dto.description.trim() } - : {}), + ...details, ...(dto.images !== undefined ? { images: { create: this.imageCreates(dto.images) } } : {}), @@ -207,12 +241,11 @@ export class SpecialOrdersService { } private async notifyCreated(row: SpecialOrderRow) { - const categoryLabel = CATEGORY_LABEL[row.category]; - const customerName = displayName(row.user); + const orderCode = `BL-S-${row.number}`; const customerPhone = row.user.cellNumber; - const userMessage = `بلوط: سفارش ویژه «${row.name}» (${categoryLabel}) ثبت شد. به‌زودی برای هماهنگی با شما تماس می‌گیریم.`; - const adminMessage = `بلوط: سفارش ویژه جدید\n${categoryLabel} — ${row.name}\nکاربر: ${customerName}\nموبایل: ${customerPhone}`; + const userMessage = `سفارش شما به شماره ی${orderCode} ثبت شد\nاز شما سپاسگزاریم و به زودی با شما تماس خواهیم گرفت`; + const adminMessage = `سفارش ویژه ی جدید به شماره سفارش ${orderCode}`; try { await this.sms.send({ to: customerPhone, message: userMessage }); @@ -268,6 +301,128 @@ export class SpecialOrdersService { })); } + private async resolveDetails( + dto: { + category?: SpecialOrderCategory; + name?: string; + description?: string; + neededDate?: string | null; + receiveTime?: string; + servings?: number | null; + cakeFlavor?: string; + filling?: string; + baloutSuggestedCombo?: boolean; + coating?: string; + designStyle?: string; + cakeText?: string; + decorations?: string; + deliveryType?: DeliveryType | null; + branchId?: string | null; + }, + options?: { partial?: boolean }, + ) { + const partial = options?.partial ?? false; + const data: { + category?: SpecialOrderCategory; + name?: string; + description?: string; + neededDate?: Date | null; + receiveTime?: string; + servings?: number | null; + cakeFlavor?: string; + filling?: string; + baloutSuggestedCombo?: boolean; + coating?: string; + designStyle?: string; + cakeText?: string; + decorations?: string; + deliveryType?: DeliveryType | null; + branchId?: string | null; + branchName?: string; + } = {}; + + if (dto.category !== undefined) data.category = dto.category; + if (dto.name !== undefined) data.name = dto.name.trim(); + if (dto.description !== undefined) { + data.description = dto.description.trim(); + } else if (!partial) { + data.description = ''; + } + if (!partial || dto.neededDate !== undefined) { + data.neededDate = + dto.neededDate === null || dto.neededDate === undefined + ? null + : new Date(dto.neededDate); + } + if (!partial || dto.receiveTime !== undefined) { + data.receiveTime = dto.receiveTime?.trim() ?? ''; + } + if (!partial || dto.servings !== undefined) { + data.servings = + dto.servings === null || dto.servings === undefined + ? null + : dto.servings; + } + if (!partial || dto.cakeFlavor !== undefined) { + data.cakeFlavor = dto.cakeFlavor?.trim() ?? ''; + } + if (!partial || dto.filling !== undefined) { + data.filling = dto.filling?.trim() ?? ''; + } + if (!partial || dto.baloutSuggestedCombo !== undefined) { + data.baloutSuggestedCombo = Boolean(dto.baloutSuggestedCombo); + } + if (!partial || dto.coating !== undefined) { + data.coating = dto.coating?.trim() ?? ''; + } + if (!partial || dto.designStyle !== undefined) { + data.designStyle = dto.designStyle?.trim() ?? ''; + } + if (!partial || dto.cakeText !== undefined) { + data.cakeText = dto.cakeText?.trim() ?? ''; + } + if (!partial || dto.decorations !== undefined) { + data.decorations = dto.decorations?.trim() ?? ''; + } + if (!partial || dto.deliveryType !== undefined) { + data.deliveryType = + dto.deliveryType === undefined ? null : dto.deliveryType; + } + + if (!partial || dto.branchId !== undefined) { + const branchId = + dto.branchId === undefined || + dto.branchId === null || + dto.branchId === '' + ? null + : dto.branchId; + if (branchId) { + const branch = await this.prisma.branch.findUnique({ + where: { id: branchId }, + select: { id: true, name: true }, + }); + if (!branch) throw new NotFoundException('شعبه یافت نشد'); + data.branchId = branch.id; + data.branchName = branch.name; + } else { + data.branchId = null; + data.branchName = ''; + } + } + + if ( + !partial && + dto.deliveryType === DeliveryType.pickup && + !data.branchId + ) { + throw new BadRequestException( + 'انتخاب شعبه برای دریافت حضوری الزامی است', + ); + } + + return data; + } + private async ensureUser(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, @@ -287,10 +442,36 @@ export class SpecialOrdersService { private serialize(row: SpecialOrderRow) { return { id: row.id, + number: row.number, + code: `BL-S-${row.number}`, userId: row.userId, category: row.category, name: row.name, description: row.description, + neededDate: row.neededDate?.toISOString() ?? null, + receiveTime: row.receiveTime, + servings: row.servings, + cakeFlavor: row.cakeFlavor, + filling: row.filling, + baloutSuggestedCombo: row.baloutSuggestedCombo, + coating: row.coating, + designStyle: row.designStyle, + cakeText: row.cakeText, + decorations: row.decorations, + deliveryType: row.deliveryType, + deliveryTypeLabel: row.deliveryType + ? DELIVERY_LABEL[row.deliveryType] + : null, + branchId: row.branchId, + branchName: row.branchName || row.branch?.name || '', + branch: row.branch + ? { + id: row.branch.id, + name: row.branch.name, + district: row.branch.district, + address: row.branch.address, + } + : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), images: row.images.map((image) => ({