Editing of the project's Kariz page

This commit is contained in:
2026-09-15 19:13:30 +03:30
parent 44f9849d0e
commit e61fad95c6
25 changed files with 1751 additions and 567 deletions
@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
parseAmount,
readMappedRows,
writeXlsx,
} from '../common/excel';
import { ProductsService } from './products.service';
const PRODUCT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'نوع', key: 'kind', width: 12 },
{ header: 'نام محصول', key: 'name', width: 28 },
{ header: 'کد کالا', key: 'sku', width: 16 },
{ header: 'دسته‌بندی', key: 'category', width: 16 },
{ header: 'واحد', key: 'unit', width: 10 },
{ header: 'موجودی', key: 'stockQty', width: 12 },
{ header: 'برند', key: 'brand', width: 14 },
{ header: 'مدل', key: 'model', width: 14 },
{ header: 'کشور', key: 'country', width: 12 },
{ header: 'قیمت واحد', key: 'unitPrice', width: 14 },
{ header: 'ارز', key: 'currency', width: 10 },
{ header: 'وضعیت', key: 'active', width: 12 },
{ header: 'برچسب', key: 'tags', width: 16 },
{ header: 'توضیحات', key: 'description', width: 32 },
];
const PRODUCT_ALIASES: Record<string, string> = {
_type: '_type',
type: '_type',
نوع: 'kind',
ناممحصول: 'name',
نام: 'name',
نامکالا: 'name',
کدکالا: 'sku',
sku: 'sku',
کد: 'sku',
دستهبندی: 'category',
دسته: 'category',
واحد: 'unit',
موجودی: 'stockQty',
برند: 'brand',
مدل: 'model',
کشور: 'country',
قیمتواحد: 'unitPrice',
قیمت: 'unitPrice',
ارز: 'currency',
وضعیت: 'active',
فعال: 'active',
برچسب: 'tags',
توضیحات: 'description',
};
function kindLabel(kind: string) {
return kind === 'service' ? 'خدمت' : 'کالا';
}
function parseKind(raw?: string): 'product' | 'service' {
return /خدمت|service/i.test(raw ?? '') ? 'service' : 'product';
}
function parseActive(raw?: string) {
const v = (raw ?? '').trim();
if (!v) return true;
return !/غیرفعال|inactive|خیر|no|0/i.test(v);
}
function parseTags(raw?: string) {
return (raw ?? '')
.split(/[،,|#]+/)
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20);
}
@Injectable()
export class ProductsExcelService {
constructor(
private readonly prisma: PrismaService,
private readonly products: ProductsService,
) {}
async export(organizationId: string, q?: string): Promise<Buffer> {
const items = await this.products.list(organizationId, { q });
return writeXlsx(
'Product',
PRODUCT_COLUMNS,
items.map((p) => ({
_type: 'Product',
kind: kindLabel(p.kind),
name: p.name,
sku: p.sku ?? '',
category: p.category ?? '',
unit: p.unit ?? '',
stockQty: p.stockQty ?? '',
brand: p.brand ?? '',
model: p.model ?? '',
country: p.country ?? '',
unitPrice: p.unitPrice ?? '',
currency: p.currency,
active: p.isActive ? 'فعال' : 'غیرفعال',
tags: (p.tags ?? []).join('، '),
description: p.description ?? '',
})),
);
}
async import(
organizationId: string,
file: Express.Multer.File,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PRODUCT_ALIASES);
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'product') {
continue;
}
const name = (row.name || '').trim();
if (name.length < 1) {
result.errors.push({ row: rowNum, message: 'نام محصول الزامی است' });
continue;
}
const sku = row.sku?.trim() || undefined;
const duplicate = await this.prisma.product.findFirst({
where: {
organizationId,
OR: [
...(sku ? [{ sku: { equals: sku, mode: 'insensitive' as const } }] : []),
{ name: { equals: name, mode: 'insensitive' } },
],
},
select: { id: true },
});
if (duplicate) {
result.skipped += 1;
continue;
}
try {
await this.products.create(organizationId, {
name,
kind: parseKind(row.kind),
sku,
category: row.category || undefined,
unit: row.unit || undefined,
stockQty: parseAmount(row.stockQty),
brand: row.brand || undefined,
model: row.model || undefined,
country: row.country || undefined,
tags: parseTags(row.tags),
unitPrice: parseAmount(row.unitPrice),
currency: row.currency || undefined,
isActive: parseActive(row.active),
description: row.description || undefined,
});
result.created += 1;
} catch (e) {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
return result;
}
}
+44 -1
View File
@@ -7,19 +7,32 @@ import {
Patch,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import type { Actor } from '../common/record-scope';
import { CreateProductDto, ListProductsQueryDto, UpdateProductDto } from './dto';
import { ProductsService } from './products.service';
import { ProductsExcelService } from './products-excel.service';
import { brand } from '@mento/shared';
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard)
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
constructor(
private readonly productsService: ProductsService,
private readonly excelService: ProductsExcelService,
) {}
@Get()
list(
@@ -34,6 +47,36 @@ export class ProductsController {
});
}
@Get('export')
async export(
@CurrentUser() user: Actor,
@Query('q') q: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.excelService.export(user.organizationId, q);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${brand.filePrefix}-products.xlsx"`,
});
return new StreamableFile(buffer);
}
@Post('import')
@Roles('owner', 'admin')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.import(user.organizationId, file);
}
@Get(':id')
getOne(
@CurrentUser() user: { organizationId: string },
+2 -1
View File
@@ -3,11 +3,12 @@ import { AuthModule } from '../auth/auth.module';
import { InventoryModule } from '../inventory/inventory.module';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { ProductsExcelService } from './products-excel.service';
@Module({
imports: [AuthModule, InventoryModule],
controllers: [ProductsController],
providers: [ProductsService],
providers: [ProductsService, ProductsExcelService],
exports: [ProductsService],
})
export class ProductsModule {}