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 { enum SettlementType {
cash cash
check check
cash_check
} }
enum CheckRegistrationMode { enum CheckRegistrationMode {
@@ -175,6 +176,8 @@ model Product {
code String code String
color String color String
description String? description String?
/// مشخصات آزاد محصول (نمایش در specs-extra اپ)
specs String? @db.Text
minMeterage Float @default(30) minMeterage Float @default(30)
orderMultiplier Float @default(10) orderMultiplier Float @default(10)
/// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور /// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور
@@ -194,12 +197,10 @@ model Product {
subtitle String? subtitle String?
rollMeters Float @default(50) rollMeters Float @default(50)
isNew Boolean @default(false) isNew Boolean @default(false)
brandId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
cartItems CartItem[] cartItems CartItem[]
orderItems OrderItem[] orderItems OrderItem[]
favorites ProductFavorite[] favorites ProductFavorite[]
@@ -208,7 +209,6 @@ model Product {
@@unique([companyId, code]) @@unique([companyId, code])
@@index([companyId]) @@index([companyId])
@@index([brandId])
@@index([usageCategory]) @@index([usageCategory])
@@index([season]) @@index([season])
} }
@@ -254,7 +254,6 @@ model Customer {
favorites ProductFavorite[] favorites ProductFavorite[]
catalogRequests CatalogRequest[] catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[] productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
addresses CustomerAddress[] addresses CustomerAddress[]
@@index([companyId]) @@index([companyId])
@@ -485,8 +484,9 @@ model SystemSettings {
id String @id @default("default") id String @id @default("default")
maxMeteragePerInvoice Int @default(5000) maxMeteragePerInvoice Int @default(5000)
cashInvoiceValidityHours Int @default(8) cashInvoiceValidityHours Int @default(8)
defaultSettlementCheck Boolean @default(true) defaultSettlementCheck Boolean @default(true)
defaultSettlementCash Boolean @default(true) defaultSettlementCash Boolean @default(true)
defaultSettlementCashCheck Boolean @default(false)
/// حداقل سفارش: تعداد طاقه از هر محصول (§۱۹) /// حداقل سفارش: تعداد طاقه از هر محصول (§۱۹)
minOrderRollsPerProduct Int @default(1) minOrderRollsPerProduct Int @default(1)
/// نمایش قیمت عمومی در کاتالوگ (پیش‌فرض خاموش) /// نمایش قیمت عمومی در کاتالوگ (پیش‌فرض خاموش)
@@ -502,6 +502,9 @@ model SystemSettings {
smsShippingUpdate Boolean @default(true) smsShippingUpdate Boolean @default(true)
supportPhone String? supportPhone String?
supportEmail String? supportEmail String?
supportWhatsapp String?
supportTelegram String?
supportInstagram String?
/// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... } /// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
categoryImagesJson Json? categoryImagesJson Json?
/// تعاریف دسته‌ها (کاربرد/فصل). اگر خالی باشد از مقادیر پیش‌فرض کد استفاده می‌شود. /// تعاریف دسته‌ها (کاربرد/فصل). اگر خالی باشد از مقادیر پیش‌فرض کد استفاده می‌شود.
@@ -517,6 +520,8 @@ model AppBanner {
subtitle String? subtitle String?
ctaLabel String? ctaLabel String?
ctaLink String? ctaLink String?
/// لینک کلیک روی تصویر بنر (مستقل از لینک دکمه)
bannerLink String?
imageUrl String? imageUrl String?
displayOrder Int @default(0) displayOrder Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -627,32 +632,6 @@ model ProductFavorite {
@@index([customerId]) @@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 { model CustomerLevelDef {
id String @id @default(cuid()) id String @id @default(cuid())
code String @unique code String @unique
+17 -6
View File
@@ -66,6 +66,17 @@ async function main() {
await prisma.company.createMany({ await prisma.company.createMany({
data: [ data: [
{
id: 'company-nademitex',
name: 'نادمی تکس',
slug: 'nademitex',
description: 'شرکت اصلی — دریافت تمام پرداخت‌های اپ',
isActive: true,
displayOrder: 0,
maxMeteragePerInvoice: null,
cashInvoiceValidityHours: 8,
cashInvoiceValidUntilHour: 17,
},
{ {
id: 'company-par', id: 'company-par',
name: 'شرکت پَر', name: 'شرکت پَر',
@@ -95,9 +106,9 @@ async function main() {
data: [ data: [
{ {
id: 'ba-par-1', id: 'ba-par-1',
companyId: 'company-par', companyId: 'company-nademitex',
bankName: 'بانک ملی', bankName: 'بانک ملی',
accountHolder: 'شرکت پَر', accountHolder: 'نادمی تکس',
accountNumber: '0123456789', accountNumber: '0123456789',
cardNumber: '6037-9912-3456-7890', cardNumber: '6037-9912-3456-7890',
iban: 'IR120170000000012345678901', iban: 'IR120170000000012345678901',
@@ -106,9 +117,9 @@ async function main() {
}, },
{ {
id: 'ba-par-2', id: 'ba-par-2',
companyId: 'company-par', companyId: 'company-nademitex',
bankName: 'بانک صادرات', bankName: 'بانک صادرات',
accountHolder: 'شرکت پَر', accountHolder: 'نادمی تکس',
accountNumber: '9876543210', accountNumber: '9876543210',
cardNumber: '6037-6912-3456-7891', cardNumber: '6037-6912-3456-7891',
iban: 'IR620190000000098765432101', iban: 'IR620190000000098765432101',
@@ -117,9 +128,9 @@ async function main() {
}, },
{ {
id: 'ba-fam-1', id: 'ba-fam-1',
companyId: 'company-fam-baft', companyId: 'company-nademitex',
bankName: 'بانک ملت', bankName: 'بانک ملت',
accountHolder: 'فام‌بافت', accountHolder: 'نادمی تکس',
accountNumber: '1122334455', accountNumber: '1122334455',
cardNumber: '6104-3378-9012-3456', cardNumber: '6104-3378-9012-3456',
iban: 'IR450120000000011223344556', 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 { enum SettlementType {
cash cash
check check
cash_check
} }
enum CheckRegistrationMode { enum CheckRegistrationMode {
@@ -175,6 +176,8 @@ model Product {
code String code String
color String color String
description String? description String?
/// مشخصات آزاد محصول (نمایش در specs-extra اپ)
specs String? @db.Text
minMeterage Float @default(30) minMeterage Float @default(30)
orderMultiplier Float @default(10) orderMultiplier Float @default(10)
/// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور /// قیمت هر متر (تومان) برای نمایش کاتالوگ / پیش‌فاکتور
@@ -194,12 +197,10 @@ model Product {
subtitle String? subtitle String?
rollMeters Float @default(50) rollMeters Float @default(50)
isNew Boolean @default(false) isNew Boolean @default(false)
brandId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
cartItems CartItem[] cartItems CartItem[]
orderItems OrderItem[] orderItems OrderItem[]
favorites ProductFavorite[] favorites ProductFavorite[]
@@ -208,7 +209,6 @@ model Product {
@@unique([companyId, code]) @@unique([companyId, code])
@@index([companyId]) @@index([companyId])
@@index([brandId])
@@index([usageCategory]) @@index([usageCategory])
@@index([season]) @@index([season])
} }
@@ -254,7 +254,6 @@ model Customer {
favorites ProductFavorite[] favorites ProductFavorite[]
catalogRequests CatalogRequest[] catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[] productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
addresses CustomerAddress[] addresses CustomerAddress[]
@@index([companyId]) @@index([companyId])
@@ -482,31 +481,35 @@ model Shipment {
} }
model SystemSettings { model SystemSettings {
id String @id @default("default") id String @id @default("default")
maxMeteragePerInvoice Int @default(5000) maxMeteragePerInvoice Int @default(5000)
cashInvoiceValidityHours Int @default(8) cashInvoiceValidityHours Int @default(8)
defaultSettlementCheck Boolean @default(true) defaultSettlementCheck Boolean @default(true)
defaultSettlementCash 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? agentMinUnitPrice Int?
smsOrderRegistered Boolean @default(true) smsOrderRegistered Boolean @default(true)
smsPricingReady Boolean @default(true) smsPricingReady Boolean @default(true)
smsPaymentReminder Boolean @default(true) smsPaymentReminder Boolean @default(true)
smsCheckReview Boolean @default(true) smsCheckReview Boolean @default(true)
smsShippingUpdate Boolean @default(true) smsShippingUpdate Boolean @default(true)
supportPhone String? supportPhone String?
supportEmail String? supportEmail String?
supportWhatsapp String?
supportTelegram String?
supportInstagram String?
/// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... } /// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
categoryImagesJson Json? categoryImagesJson Json?
/// تعاریف دسته‌ها (کاربرد/فصل). اگر خالی باشد از مقادیر پیش‌فرض کد استفاده می‌شود. /// تعاریف دسته‌ها (کاربرد/فصل). اگر خالی باشد از مقادیر پیش‌فرض کد استفاده می‌شود.
categoryDefsJson Json? categoryDefsJson Json?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// بنرهای قابل مدیریت بخش‌های اپ کاربر /// بنرهای قابل مدیریت بخش‌های اپ کاربر
@@ -517,6 +520,8 @@ model AppBanner {
subtitle String? subtitle String?
ctaLabel String? ctaLabel String?
ctaLink String? ctaLink String?
/// لینک کلیک روی تصویر بنر (مستقل از لینک دکمه)
bannerLink String?
imageUrl String? imageUrl String?
displayOrder Int @default(0) displayOrder Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -627,32 +632,6 @@ model ProductFavorite {
@@index([customerId]) @@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 { model CustomerLevelDef {
id String @id @default(cuid()) id String @id @default(cuid())
code String @unique 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 { NotificationsModule } from './notifications/notifications.module';
import { AgentModule } from './agent/agent.module'; import { AgentModule } from './agent/agent.module';
import { CatalogRequestsModule } from './catalog-requests/catalog-requests.module'; import { CatalogRequestsModule } from './catalog-requests/catalog-requests.module';
import { BrandsModule } from './brands/brands.module';
import { LevelsModule } from './levels/levels.module'; import { LevelsModule } from './levels/levels.module';
import { SuggestionsModule } from './suggestions/suggestions.module'; import { SuggestionsModule } from './suggestions/suggestions.module';
import { BannersModule } from './banners/banners.module'; import { BannersModule } from './banners/banners.module';
@@ -46,7 +45,6 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo
NotificationsModule, NotificationsModule,
AgentModule, AgentModule,
CatalogRequestsModule, CatalogRequestsModule,
BrandsModule,
LevelsModule, LevelsModule,
SuggestionsModule, SuggestionsModule,
BannersModule, 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 { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { normalizeBannerLink } from './banner-link.util';
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto'; import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
const ALLOWED_SLOTS = new Set(['home_hero', 'products_hero', 'categories_hero']); const ALLOWED_SLOTS = new Set(['home_hero', 'products_hero', 'categories_hero']);
@@ -28,6 +29,7 @@ export class BannersService {
subtitle: dto.subtitle?.trim() || null, subtitle: dto.subtitle?.trim() || null,
ctaLabel: dto.ctaLabel?.trim() || null, ctaLabel: dto.ctaLabel?.trim() || null,
ctaLink: dto.ctaLink?.trim() || null, ctaLink: dto.ctaLink?.trim() || null,
bannerLink: normalizeBannerLink(dto.bannerLink),
imageUrl: this.normalizeImage(dto.imageUrl), imageUrl: this.normalizeImage(dto.imageUrl),
displayOrder: dto.displayOrder ?? 0, displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
@@ -52,6 +54,10 @@ export class BannersService {
dto.ctaLabel === undefined ? undefined : dto.ctaLabel?.trim() || null, dto.ctaLabel === undefined ? undefined : dto.ctaLabel?.trim() || null,
ctaLink: ctaLink:
dto.ctaLink === undefined ? undefined : dto.ctaLink?.trim() || null, dto.ctaLink === undefined ? undefined : dto.ctaLink?.trim() || null,
bannerLink:
dto.bannerLink === undefined
? undefined
: normalizeBannerLink(dto.bannerLink),
imageUrl: imageUrl:
dto.imageUrl === undefined dto.imageUrl === undefined
? undefined ? undefined
@@ -109,6 +115,7 @@ export class BannersService {
subtitle: string | null; subtitle: string | null;
ctaLabel: string | null; ctaLabel: string | null;
ctaLink: string | null; ctaLink: string | null;
bannerLink: string | null;
imageUrl: string | null; imageUrl: string | null;
displayOrder: number; displayOrder: number;
isActive: boolean; isActive: boolean;
@@ -125,6 +132,7 @@ export class BannersService {
subtitle: row.subtitle ?? '', subtitle: row.subtitle ?? '',
ctaLabel: row.ctaLabel ?? '', ctaLabel: row.ctaLabel ?? '',
ctaLink: row.ctaLink ?? '', ctaLink: row.ctaLink ?? '',
bannerLink: row.bannerLink ?? '',
imageUrl: row.imageUrl ?? '', imageUrl: row.imageUrl ?? '',
displayOrder: row.displayOrder, displayOrder: row.displayOrder,
isActive: row.isActive, isActive: row.isActive,
+8
View File
@@ -29,6 +29,10 @@ export class CreateBannerDto {
@IsString() @IsString()
ctaLink?: string | null; ctaLink?: string | null;
@IsOptional()
@IsString()
bannerLink?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
imageUrl?: string | null; imageUrl?: string | null;
@@ -79,6 +83,10 @@ export class UpdateBannerDto {
@IsString() @IsString()
ctaLink?: string | null; ctaLink?: string | null;
@IsOptional()
@IsString()
bannerLink?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
imageUrl?: string | null; 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, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { PrismaService } from '../prisma/prisma.service';
import { import {
SubmitCashReceiptDto, SubmitCashReceiptDto,
@@ -12,7 +17,10 @@ import {
} from './dto/cash-payment.dto'; } from './dto/cash-payment.dto';
import { joinFullName } from '../common/utils/name'; import { joinFullName } from '../common/utils/name';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { requirePaymentRecipientCompany } from '../companies/default-company';
import {
usesCashReceiptPhase,
} from '../common/utils/settlement-type';
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */ /** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [ const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
OrderStatus.cash_in_progress, OrderStatus.cash_in_progress,
@@ -42,7 +50,7 @@ export class CashPaymentsService {
if (order.userId !== userId) { if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست'); throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
} }
if (order.settlementType !== SettlementType.cash) { if (!usesCashReceiptPhase(order.settlementType)) {
throw new BadRequestException('این سفارش تسویه نقدی نیست'); throw new BadRequestException('این سفارش تسویه نقدی نیست');
} }
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) { if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
@@ -59,18 +67,11 @@ export class CashPaymentsService {
); );
} }
const companyIds = [ const paymentRecipient = await requirePaymentRecipientCompany(this.prisma);
...new Set(
[
order.companyId,
...order.items.map((item) => item.companyId).filter(Boolean),
].filter(Boolean),
),
];
const account = await this.prisma.bankAccount.findFirst({ const account = await this.prisma.bankAccount.findFirst({
where: { where: {
id: dto.destinationAccountId, id: dto.destinationAccountId,
companyId: { in: companyIds }, companyId: paymentRecipient.id,
isActive: true, isActive: true,
}, },
}); });
@@ -175,7 +176,10 @@ export class CashPaymentsService {
// رد رسید باید مشتری را به مرحله پرداخت برگرداند تا بتواند رسید تازه بفرستد. // رد رسید باید مشتری را به مرحله پرداخت برگرداند تا بتواند رسید تازه بفرستد.
let nextStatus: OrderStatus | null = null; let nextStatus: OrderStatus | null = null;
if (dto.status === CashPaymentStatus.approved) { 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) { } else if (dto.status === CashPaymentStatus.rejected) {
nextStatus = OrderStatus.awaiting_cash_payment; nextStatus = OrderStatus.awaiting_cash_payment;
} else if (dto.status === CashPaymentStatus.expired) { } else if (dto.status === CashPaymentStatus.expired) {
@@ -185,7 +189,16 @@ export class CashPaymentsService {
if (nextStatus && nextStatus !== receipt.order.status) { if (nextStatus && nextStatus !== receipt.order.status) {
await tx.order.update({ await tx.order.update({
where: { id: receipt.orderId }, 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({ await tx.orderStatusHistory.create({
data: { 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') @AuthType('admin')
@Roles('admin', 'sales', 'warehouse') @Roles('admin', 'sales', 'warehouse')
@Get('admin/catalog-requests') @Get('admin/catalog-requests')
adminList(@Query('status') status?: CatalogRequestStatus) { adminList(
return this.service.listAdmin(status); @Query('status') status?: CatalogRequestStatus,
@Query('registeredOn') registeredOn?: string,
) {
return this.service.listAdmin(status, registeredOn);
} }
@AuthType('admin') @AuthType('admin')
@@ -13,6 +13,7 @@ import {
UpdateCatalogRequestStatusDto, UpdateCatalogRequestStatusDto,
UpdateCatalogItemHolderDto, UpdateCatalogItemHolderDto,
} from './dto/catalog-request.dto'; } from './dto/catalog-request.dto';
import { dayRangeForRegisteredOn } from './catalog-request-date.util';
const STATUS_FLOW: CatalogRequestStatus[] = [ const STATUS_FLOW: CatalogRequestStatus[] = [
'new', 'new',
@@ -27,9 +28,18 @@ const STATUS_FLOW: CatalogRequestStatus[] = [
export class CatalogRequestsService { export class CatalogRequestsService {
constructor(private readonly prisma: PrismaService) {} 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({ 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' }, orderBy: { createdAt: 'desc' },
include: { include: {
customer: { 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: [ awaiting_cash_confirmation: [
OrderStatus.cash_confirmed, OrderStatus.cash_confirmed,
OrderStatus.check_in_progress,
OrderStatus.awaiting_cash_payment, OrderStatus.awaiting_cash_payment,
], ],
cash_confirmed: [OrderStatus.final_approved], 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); return this.companiesService.findAll(true);
} }
@Public()
@Get('payment/bank-accounts')
listPaymentBankAccounts() {
return this.companiesService.listPaymentBankAccounts();
}
@AuthType('any') @AuthType('any')
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
+27 -2
View File
@@ -1,8 +1,13 @@
import { import {
BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import {
isPaymentRecipientCompany,
requirePaymentRecipientCompany,
} from './default-company';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { resolveMaxMeterage } from '../common/utils/meterage'; import { resolveMaxMeterage } from '../common/utils/meterage';
@@ -146,8 +151,21 @@ export class CompaniesService {
return this.mapCompany(company); return this.mapCompany(company);
} }
/** حساب‌های واریز پرداخت مشتری — همیشه نادمی تکس */
async listPaymentBankAccounts() {
const recipient = await requirePaymentRecipientCompany(this.prisma);
return this.listBankAccountsForCompany(recipient.id);
}
/**
* لیست حساب برای اپ/ادمین: پرداخت همیشه به نادمی تکس است، نه شرکت مالک محصول.
*/
async listBankAccounts(companyId: string) { async listBankAccounts(companyId: string) {
await this.ensureCompany(companyId); await this.ensureCompany(companyId);
return this.listPaymentBankAccounts();
}
private async listBankAccountsForCompany(companyId: string) {
const accounts = await this.prisma.bankAccount.findMany({ const accounts = await this.prisma.bankAccount.findMany({
where: { companyId }, where: { companyId },
orderBy: { displayOrder: 'asc' }, orderBy: { displayOrder: 'asc' },
@@ -156,10 +174,16 @@ export class CompaniesService {
} }
async createBankAccount(companyId: string, dto: CreateBankAccountDto) { 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({ const account = await this.prisma.bankAccount.create({
data: { data: {
companyId, companyId: recipient.id,
bankName: dto.bankName.trim(), bankName: dto.bankName.trim(),
accountHolder: dto.accountHolder.trim(), accountHolder: dto.accountHolder.trim(),
accountNumber: dto.accountNumber.trim(), accountNumber: dto.accountNumber.trim(),
@@ -234,6 +258,7 @@ export class CompaniesService {
id: company.id, id: company.id,
name: company.name, name: company.name,
slug: company.slug, slug: company.slug,
isPaymentRecipient: isPaymentRecipientCompany(company),
description: company.description ?? '', description: company.description ?? '',
logoUrl: company.logoUrl, logoUrl: company.logoUrl,
logo: company.logoUrl ?? undefined, 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'; } from './dto/order.dto';
import { generateOrderNumber } from '../common/utils/order-number'; import { generateOrderNumber } from '../common/utils/order-number';
import { calculateCheckRass } from '../common/utils/check-rass'; import { calculateCheckRass } from '../common/utils/check-rass';
import {
usesCashReceiptPhase,
usesCheckSettlement,
} from '../common/utils/settlement-type';
import { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry'; import { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry';
import { import {
resolveAdminStatusFilter, resolveAdminStatusFilter,
@@ -217,25 +221,34 @@ export class OrdersService {
); );
} }
const isCash = dto.settlementType === SettlementType.cash; const cashFirst = usesCashReceiptPhase(dto.settlementType);
const nextStatus = isCash const checkOnly =
dto.settlementType === SettlementType.check;
const nextStatus = cashFirst
? OrderStatus.awaiting_cash_payment ? OrderStatus.awaiting_cash_payment
: OrderStatus.check_in_progress; : OrderStatus.check_in_progress;
assertTransition(order.status, nextStatus); assertTransition(order.status, nextStatus);
const cashCompanies = isCash const cashCompanies = cashFirst
? await this.companiesForOrder(order) ? await this.companiesForOrder(order)
: []; : [];
const settlementNote =
dto.settlementType === SettlementType.cash
? 'مشتری تسویه نقدی را انتخاب کرد'
: dto.settlementType === SettlementType.cash_check
? 'مشتری تسویه نقدی-چکی را انتخاب کرد'
: 'مشتری تسویه چکی را انتخاب کرد';
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.order.update({ await tx.order.update({
where: { id }, where: { id },
data: { data: {
settlementType: dto.settlementType, settlementType: dto.settlementType,
checkMode: isCash checkMode: checkOnly
? null ? (dto.checkMode ?? CheckRegistrationMode.full)
: (dto.checkMode ?? CheckRegistrationMode.full), : null,
cashInvoiceExpiresAt: isCash cashInvoiceExpiresAt: cashFirst
? earliestCashInvoiceExpiry(cashCompanies) ? earliestCashInvoiceExpiry(cashCompanies)
: null, : null,
cashReminderSentAt: null, cashReminderSentAt: null,
@@ -248,9 +261,7 @@ export class OrdersService {
fromStatus: order.status, fromStatus: order.status,
toStatus: nextStatus, toStatus: nextStatus,
changedBy: 'customer', changedBy: 'customer',
note: isCash note: settlementNote,
? 'مشتری تسویه نقدی را انتخاب کرد'
: 'مشتری تسویه چکی را انتخاب کرد',
}, },
}); });
}); });
@@ -575,7 +586,7 @@ export class OrdersService {
if (order.userId !== userId) { if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست'); throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
} }
if (order.settlementType !== SettlementType.check) { if (!usesCheckSettlement(order.settlementType)) {
throw new BadRequestException('این سفارش تسویه چکی نیست'); throw new BadRequestException('این سفارش تسویه چکی نیست');
} }
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) { 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 { import {
colorVariantImageLists, colorVariantImageLists,
parseColorEntries, parseColorEntries,
parseGeneralImageUrls, parseGeneralImageUrls,
} from './color-entries'; } 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', () => { describe('product image separation', () => {
it('keeps general images separate from color variant urls', () => { 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', () => { it('does not merge color images into general list when only imageUrlsJson is set', () => {
const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]); const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]);
expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([ 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 { export class ProductColorJsonItemDto {
@IsString() @IsString()
@@ -38,7 +88,7 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
for (const item of value) { for (const item of value) {
if (typeof item === 'string') { if (typeof item === 'string') {
const name = item.trim(); const name = item.trim();
if (name) out.push({ name }); if (name) out.push(Object.assign(new ProductColorJsonItemDto(), { name }));
continue; continue;
} }
if (item && typeof item === 'object') { if (item && typeof item === 'object') {
@@ -54,11 +104,13 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
typeof raw.imageUrl === 'string' && raw.imageUrl.trim() typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
? raw.imageUrl.trim() ? raw.imageUrl.trim()
: undefined; : undefined;
out.push({ out.push(
name, Object.assign(new ProductColorJsonItemDto(), {
imageUrl, name,
imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined, imageUrl,
}); imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined,
}),
);
} }
} }
return out; return out;
+17 -10
View File
@@ -7,21 +7,19 @@ import {
IsString, IsString,
Min, Min,
MinLength, MinLength,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsColorsJson,
ProductColorJsonItemDto, ProductColorJsonItemDto,
transformColorsJson, transformColorsJson,
} from './product-color-json.dto'; } from './product-color-json.dto';
export class CreateProductDto { export class CreateProductDto {
@IsString() /** خالی = شرکت پیش‌فرض (نادمی تکس) */
companyId!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
brandId?: string | null; companyId?: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -44,6 +42,11 @@ export class CreateProductDto {
@IsString() @IsString()
description?: string; description?: string;
/** مشخصات آزاد — نمایش در specs-extra اپ */
@IsOptional()
@IsString()
specs?: string;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()
@@ -139,8 +142,7 @@ export class CreateProductDto {
@IsOptional() @IsOptional()
@Transform(({ value }) => transformColorsJson(value)) @Transform(({ value }) => transformColorsJson(value))
@IsArray() @IsArray()
@ValidateNested({ each: true }) @IsColorsJson()
@Type(() => ProductColorJsonItemDto)
colorsJson?: ProductColorJsonItemDto[]; colorsJson?: ProductColorJsonItemDto[];
@IsOptional() @IsOptional()
@@ -149,18 +151,23 @@ export class CreateProductDto {
imageUrlsJson?: string[]; imageUrlsJson?: string[];
@IsOptional() @IsOptional()
@IsArray()
@IsString({ each: true })
tagsJson?: string[]; tagsJson?: string[];
} }
/** همهٔ فیلدهای Create به‌جز companyId — شامل brandId برای PATCH ادمین */ /** همهٔ فیلدهای Create به‌جز companyId — companyId در PATCH برای تغییر شرکت محصول */
export class UpdateProductDto extends PartialType( export class UpdateProductDto extends PartialType(
OmitType(CreateProductDto, ['companyId'] as const), OmitType(CreateProductDto, ['companyId'] as const),
) { ) {
@IsOptional()
@IsString()
companyId?: string;
@IsOptional() @IsOptional()
@Transform(({ value }) => transformColorsJson(value)) @Transform(({ value }) => transformColorsJson(value))
@IsArray() @IsArray()
@ValidateNested({ each: true }) @IsColorsJson()
@Type(() => ProductColorJsonItemDto)
colorsJson?: ProductColorJsonItemDto[]; 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) { setActive(@Param('id') id: string, @Body() dto: SetProductActiveDto) {
return this.productsService.setActive(id, dto.isActive); 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 { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -9,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from './dto/product.dto'; import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
import { serializeColorsJsonForDb } from './dto/product-color-json.dto'; import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
import { loadCatalogCategories } from '../catalog/category-defs'; import { loadCatalogCategories } from '../catalog/category-defs';
import { resolveProductCompanyId } from '../companies/default-company';
import { import {
colorVariantImageLists, colorVariantImageLists,
parseColorEntries, parseColorEntries,
@@ -17,7 +17,6 @@ import {
const productInclude = { const productInclude = {
company: { select: { name: true } }, company: { select: { name: true } },
brand: { select: { id: true, name: true } },
}; };
@Injectable() @Injectable()
@@ -118,17 +117,20 @@ export class ProductsService {
async create(dto: CreateProductDto, preservedColorsJson?: unknown) { async create(dto: CreateProductDto, preservedColorsJson?: unknown) {
const code = (dto.code || dto.productCode || '').trim(); const code = (dto.code || dto.productCode || '').trim();
if (!code) throw new ConflictException('کد محصول الزامی است'); if (!code) throw new ConflictException('کد محصول الزامی است');
const brandId = await this.resolveBrandId(dto.brandId ?? null); const companyId = await resolveProductCompanyId(
this.prisma,
dto.companyId,
);
try { try {
const product = await this.prisma.product.create({ const product = await this.prisma.product.create({
data: { data: {
companyId: dto.companyId, companyId,
brandId,
name: dto.name.trim(), name: dto.name.trim(),
code, code,
color: dto.color.trim(), color: dto.color.trim(),
description: dto.description?.trim() || null, description: dto.description?.trim() || null,
specs: dto.specs?.trim() || null,
minMeterage: dto.minMeterage ?? 30, minMeterage: dto.minMeterage ?? 30,
orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10, orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10,
basePrice: this.pickNumber(dto.basePrice, dto.price), basePrice: this.pickNumber(dto.basePrice, dto.price),
@@ -177,13 +179,18 @@ export class ProductsService {
: dto.productCode !== undefined : dto.productCode !== undefined
? dto.productCode.trim() ? dto.productCode.trim()
: undefined; : undefined;
const brandId = await this.resolveBrandId(dto.brandId);
try { try {
const product = await this.prisma.product.update({ const product = await this.prisma.product.update({
where: { id }, where: { id },
data: { data: {
...(brandId !== undefined ? { brandId } : {}), ...(dto.companyId !== undefined
? {
companyId: await resolveProductCompanyId(
this.prisma,
dto.companyId,
),
}
: {}),
name: dto.name?.trim(), name: dto.name?.trim(),
code, code,
color: dto.color?.trim(), color: dto.color?.trim(),
@@ -191,6 +198,8 @@ export class ProductsService {
dto.description === undefined dto.description === undefined
? undefined ? undefined
: dto.description?.trim() || null, : dto.description?.trim() || null,
specs:
dto.specs === undefined ? undefined : dto.specs?.trim() || null,
minMeterage: dto.minMeterage, minMeterage: dto.minMeterage,
orderMultiplier: orderMultiplier:
dto.orderMultiplier !== undefined dto.orderMultiplier !== undefined
@@ -268,6 +277,35 @@ export class ProductsService {
return this.map(product); 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) { async listFavoriteIds(customerId: string) {
const rows = await this.prisma.productFavorite.findMany({ const rows = await this.prisma.productFavorite.findMany({
where: { customerId }, where: { customerId },
@@ -316,17 +354,6 @@ export class ProductsService {
return product; 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( private map(
product: { product: {
id: string; id: string;
@@ -335,6 +362,7 @@ export class ProductsService {
code: string; code: string;
color: string; color: string;
description: string | null; description: string | null;
specs?: string | null;
minMeterage: number; minMeterage: number;
orderMultiplier: number; orderMultiplier: number;
basePrice: number | null; basePrice: number | null;
@@ -343,7 +371,6 @@ export class ProductsService {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
company: { name: string }; company: { name: string };
brand?: { id: string; name: string } | null;
usageCategory?: string; usageCategory?: string;
season?: string; season?: string;
widthCm?: number | null; widthCm?: number | null;
@@ -381,13 +408,12 @@ export class ProductsService {
catalogCode: product.code, catalogCode: product.code,
/** @deprecated use `name` */ /** @deprecated use `name` */
catalogName: product.name, catalogName: product.name,
brandId: product.brand?.id ?? null,
brandName: product.brand?.name ?? null,
name: product.name, name: product.name,
code: product.code, code: product.code,
productCode: product.code, productCode: product.code,
color: product.color, color: product.color,
description: product.description ?? undefined, description: product.description ?? undefined,
specs: product.specs?.trim() ? product.specs : undefined,
minMeterage: product.minMeterage, minMeterage: product.minMeterage,
orderMultiplier: product.orderMultiplier, orderMultiplier: product.orderMultiplier,
orderStep: product.orderMultiplier, orderStep: product.orderMultiplier,
+16
View File
@@ -22,6 +22,10 @@ export class UpdateSettingsDto {
@IsBoolean() @IsBoolean()
defaultSettlementCash?: boolean; defaultSettlementCash?: boolean;
@IsOptional()
@IsBoolean()
defaultSettlementCashCheck?: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
smsOrderRegistered?: boolean; smsOrderRegistered?: boolean;
@@ -70,6 +74,18 @@ export class UpdateSettingsDto {
@IsString() @IsString()
supportEmail?: string | null; supportEmail?: string | null;
@IsOptional()
@IsString()
supportWhatsapp?: string | null;
@IsOptional()
@IsString()
supportTelegram?: string | null;
@IsOptional()
@IsString()
supportInstagram?: string | null;
@IsOptional() @IsOptional()
@IsObject() @IsObject()
categoryImagesJson?: Record<string, string | null>; categoryImagesJson?: Record<string, string | null>;
+27
View File
@@ -22,6 +22,12 @@ export class SettingsService {
return { return {
supportPhone: settings.supportPhone, supportPhone: settings.supportPhone,
supportEmail: settings.supportEmail, 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, cashInvoiceValidityHours: dto.cashInvoiceValidityHours,
defaultSettlementCheck: dto.defaultSettlementCheck, defaultSettlementCheck: dto.defaultSettlementCheck,
defaultSettlementCash: dto.defaultSettlementCash, defaultSettlementCash: dto.defaultSettlementCash,
defaultSettlementCashCheck: dto.defaultSettlementCashCheck,
minOrderRollsPerProduct: dto.minOrderRollsPerProduct, minOrderRollsPerProduct: dto.minOrderRollsPerProduct,
showCatalogPublicPrice: dto.showCatalogPublicPrice, showCatalogPublicPrice: dto.showCatalogPublicPrice,
allowAgentPricing: dto.allowAgentPricing, allowAgentPricing: dto.allowAgentPricing,
@@ -63,6 +70,18 @@ export class SettingsService {
dto.supportEmail === undefined dto.supportEmail === undefined
? undefined ? undefined
: dto.supportEmail?.trim() || null, : 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, categoryDefsJson: nextDefs,
categoryImagesJson: categoryImagesJson:
dto.categoryImagesJson === undefined dto.categoryImagesJson === undefined
@@ -87,6 +106,7 @@ export class SettingsService {
cashInvoiceValidityHours: number; cashInvoiceValidityHours: number;
defaultSettlementCheck: boolean; defaultSettlementCheck: boolean;
defaultSettlementCash: boolean; defaultSettlementCash: boolean;
defaultSettlementCashCheck?: boolean;
minOrderRollsPerProduct?: number; minOrderRollsPerProduct?: number;
showCatalogPublicPrice?: boolean; showCatalogPublicPrice?: boolean;
allowAgentPricing?: boolean; allowAgentPricing?: boolean;
@@ -98,6 +118,9 @@ export class SettingsService {
smsShippingUpdate: boolean; smsShippingUpdate: boolean;
supportPhone: string | null; supportPhone: string | null;
supportEmail: string | null; supportEmail: string | null;
supportWhatsapp?: string | null;
supportTelegram?: string | null;
supportInstagram?: string | null;
categoryImagesJson?: unknown; categoryImagesJson?: unknown;
categoryDefsJson?: unknown; categoryDefsJson?: unknown;
updatedAt: Date; updatedAt: Date;
@@ -109,6 +132,7 @@ export class SettingsService {
cashInvoiceValidityHours: settings.cashInvoiceValidityHours, cashInvoiceValidityHours: settings.cashInvoiceValidityHours,
defaultSettlementCheck: settings.defaultSettlementCheck, defaultSettlementCheck: settings.defaultSettlementCheck,
defaultSettlementCash: settings.defaultSettlementCash, defaultSettlementCash: settings.defaultSettlementCash,
defaultSettlementCashCheck: settings.defaultSettlementCashCheck ?? false,
minOrderRollsPerProduct: settings.minOrderRollsPerProduct ?? 1, minOrderRollsPerProduct: settings.minOrderRollsPerProduct ?? 1,
showCatalogPublicPrice: settings.showCatalogPublicPrice ?? false, showCatalogPublicPrice: settings.showCatalogPublicPrice ?? false,
allowAgentPricing: settings.allowAgentPricing ?? false, allowAgentPricing: settings.allowAgentPricing ?? false,
@@ -120,6 +144,9 @@ export class SettingsService {
smsShippingUpdate: settings.smsShippingUpdate, smsShippingUpdate: settings.smsShippingUpdate,
supportPhone: settings.supportPhone, supportPhone: settings.supportPhone,
supportEmail: settings.supportEmail, supportEmail: settings.supportEmail,
supportWhatsapp: settings.supportWhatsapp ?? null,
supportTelegram: settings.supportTelegram ?? null,
supportInstagram: settings.supportInstagram ?? null,
categoryImagesJson: asImageMap(settings.categoryImagesJson), categoryImagesJson: asImageMap(settings.categoryImagesJson),
categoryDefsJson: defs, categoryDefsJson: defs,
updatedAt: settings.updatedAt.toISOString(), updatedAt: settings.updatedAt.toISOString(),
+3
View File
@@ -15,6 +15,7 @@ const PRODUCT_INCLUDE = {
color: true, color: true,
subtitle: true, subtitle: true,
isNew: true, isNew: true,
isActive: true,
colorsJson: true, colorsJson: true,
imageUrlsJson: true, imageUrlsJson: true,
widthCm: true, widthCm: true,
@@ -142,6 +143,7 @@ export class SuggestionsService {
color: string; color: string;
subtitle: string | null; subtitle: string | null;
isNew: boolean; isNew: boolean;
isActive: boolean;
colorsJson: unknown; colorsJson: unknown;
imageUrlsJson: unknown; imageUrlsJson: unknown;
widthCm: number | null; widthCm: number | null;
@@ -180,6 +182,7 @@ export class SuggestionsService {
widthCm: r.product.widthCm ?? undefined, widthCm: r.product.widthCm ?? undefined,
composition: r.product.composition?.trim() || undefined, composition: r.product.composition?.trim() || undefined,
isNew: r.product.isNew, isNew: r.product.isNew,
productIsActive: r.product.isActive,
note: r.note ?? undefined, note: r.note ?? undefined,
isActive: r.isActive, isActive: r.isActive,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),