240 lines
7.1 KiB
TypeScript
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);
|
|
}
|