Initial admin edits
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+367
-446
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('لینک بنر نامعتبر است');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user