Files
crm/apps/api/src/products/products.service.ts
T

240 lines
7.1 KiB
TypeScript

import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import type {
CreateProductRequest,
ProductDto,
UpdateProductRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { InventoryService } from '../inventory/inventory.service';
type ProductRow = {
id: string;
kind: string;
name: string;
sku: string | null;
description: string | null;
category: string | null;
unit: string | null;
stockQty: number | null;
brand: string | null;
model: string | null;
country: string | null;
tags: string[];
unitPrice: Prisma.Decimal | null;
currency: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class ProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly inventory: InventoryService,
) {}
async list(
organizationId: string,
opts?: {
activeOnly?: boolean;
q?: string;
category?: string;
kind?: string;
},
): Promise<ProductDto[]> {
const rows = await this.prisma.product.findMany({
where: {
organizationId,
...(opts?.activeOnly ? { isActive: true } : {}),
...(opts?.category?.trim()
? { category: opts.category.trim() }
: {}),
...(opts?.kind ? { kind: opts.kind } : {}),
...(opts?.q?.trim()
? {
OR: [
{ name: { contains: opts.q.trim(), mode: 'insensitive' } },
{ sku: { contains: opts.q.trim(), mode: 'insensitive' } },
{
description: {
contains: opts.q.trim(),
mode: 'insensitive',
},
},
{
category: {
contains: opts.q.trim(),
mode: 'insensitive',
},
},
],
}
: {}),
},
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async get(organizationId: string, id: string): Promise<ProductDto> {
const row = await this.prisma.product.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('محصول پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
dto: CreateProductRequest,
): Promise<ProductDto> {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام محصول الزامی است');
const row = await this.prisma.product.create({
data: {
organizationId,
kind: dto.kind === 'service' ? 'service' : 'product',
name,
sku: emptyToNull(dto.sku),
description: emptyToNull(dto.description),
category: emptyToNull(dto.category),
unit: emptyToNull(dto.unit) ?? 'عدد',
stockQty: normalizeStock(dto.stockQty),
brand: emptyToNull(dto.brand),
model: emptyToNull(dto.model),
country: emptyToNull(dto.country),
tags: normalizeTags(dto.tags),
unitPrice:
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
? null
: new Prisma.Decimal(dto.unitPrice),
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
isActive: dto.isActive ?? true,
},
});
await this.inventory.onProductCreated(
organizationId,
row.id,
row.stockQty,
row.kind,
);
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateProductRequest,
): Promise<ProductDto> {
await this.ensureExists(organizationId, id);
const data: Prisma.ProductUpdateInput = {};
if (dto.kind !== undefined) {
data.kind = dto.kind === 'service' ? 'service' : 'product';
}
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام محصول الزامی است');
data.name = name;
}
if ('sku' in dto) data.sku = emptyToNull(dto.sku);
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('category' in dto) data.category = emptyToNull(dto.category);
if ('unit' in dto) data.unit = emptyToNull(dto.unit) ?? 'عدد';
if ('stockQty' in dto) data.stockQty = normalizeStock(dto.stockQty);
if ('brand' in dto) data.brand = emptyToNull(dto.brand);
if ('model' in dto) data.model = emptyToNull(dto.model);
if ('country' in dto) data.country = emptyToNull(dto.country);
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
if ('unitPrice' in dto) {
data.unitPrice =
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
? null
: new Prisma.Decimal(dto.unitPrice);
}
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const row = await this.prisma.product.update({ where: { id }, data });
if ('stockQty' in dto) {
await this.inventory.onProductStockEdited(
organizationId,
row.id,
row.stockQty,
row.kind,
);
}
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
await this.ensureExists(organizationId, id);
await this.prisma.product.delete({ where: { id } });
}
async ensureInOrg(organizationId: string, productId: string): Promise<void> {
await this.ensureExists(organizationId, productId);
}
private async ensureExists(organizationId: string, id: string) {
const found = await this.prisma.product.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!found) throw new NotFoundException('محصول پیدا نشد');
}
private toDto(row: ProductRow): ProductDto {
return {
id: row.id,
kind: row.kind === 'service' ? 'service' : 'product',
name: row.name,
sku: row.sku,
description: row.description,
category: row.category,
unit: row.unit,
stockQty: row.stockQty,
brand: row.brand,
model: row.model,
country: row.country,
tags: row.tags ?? [],
unitPrice: row.unitPrice == null ? null : Number(row.unitPrice),
currency: row.currency,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeStock(value?: number | null): number | null {
if (value == null) return null;
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
function normalizeTags(tags?: string[] | null): string[] {
if (!tags?.length) return [];
const out: string[] = [];
for (const t of tags) {
const s = String(t ?? '').trim();
if (s && !out.includes(s)) out.push(s.slice(0, 40));
}
return out.slice(0, 20);
}