Phase 0 - Base modules (100% copy): - shared/ (errors, middleware, services, utils, types) - auth, users, tenants (multi-tenancy) - ai, audit, notifications, mcp, payment-terminals - billing-usage, branches, companies, core Phase 1 - Modules to adapt (70-95%): - partners (for shippers/consignees) - inventory (for refacciones) - financial (for transport costing) Phase 2 - Pattern modules (50-70%): - ordenes-transporte (from sales) - gestion-flota (from products) - viajes (from projects) Phase 3 - New transport-specific modules: - tracking (GPS, events, alerts) - tarifas-transporte (pricing, surcharges) - combustible-gastos (fuel, tolls, expenses) - carta-porte (CFDI complement 3.1) Estimated token savings: ~65% (~10,675 lines) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
301 lines
8.5 KiB
TypeScript
301 lines
8.5 KiB
TypeScript
import { FindOptionsWhere, ILike, IsNull } from 'typeorm';
|
|
import { AppDataSource } from '../../config/typeorm.js';
|
|
import { Product } from './entities/product.entity.js';
|
|
import { ProductCategory } from './entities/product-category.entity.js';
|
|
|
|
export interface ProductSearchParams {
|
|
tenantId: string;
|
|
search?: string;
|
|
categoryId?: string;
|
|
productType?: 'product' | 'service' | 'consumable' | 'kit';
|
|
isActive?: boolean;
|
|
isSellable?: boolean;
|
|
isPurchasable?: boolean;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface CategorySearchParams {
|
|
tenantId: string;
|
|
search?: string;
|
|
parentId?: string;
|
|
isActive?: boolean;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface CreateProductDto {
|
|
sku: string;
|
|
name: string;
|
|
description?: string;
|
|
shortName?: string;
|
|
barcode?: string;
|
|
categoryId?: string;
|
|
productType?: 'product' | 'service' | 'consumable' | 'kit';
|
|
salePrice?: number;
|
|
costPrice?: number;
|
|
currency?: string;
|
|
taxRate?: number;
|
|
isActive?: boolean;
|
|
isSellable?: boolean;
|
|
isPurchasable?: boolean;
|
|
}
|
|
|
|
export interface UpdateProductDto {
|
|
sku?: string;
|
|
name?: string;
|
|
description?: string | null;
|
|
shortName?: string | null;
|
|
barcode?: string | null;
|
|
categoryId?: string | null;
|
|
productType?: 'product' | 'service' | 'consumable' | 'kit';
|
|
salePrice?: number;
|
|
costPrice?: number;
|
|
currency?: string;
|
|
taxRate?: number;
|
|
isActive?: boolean;
|
|
isSellable?: boolean;
|
|
isPurchasable?: boolean;
|
|
}
|
|
|
|
export interface CreateCategoryDto {
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
parentId?: string;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export interface UpdateCategoryDto {
|
|
code?: string;
|
|
name?: string;
|
|
description?: string | null;
|
|
parentId?: string | null;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
class ProductsServiceClass {
|
|
private get productRepository() {
|
|
return AppDataSource.getRepository(Product);
|
|
}
|
|
|
|
private get categoryRepository() {
|
|
return AppDataSource.getRepository(ProductCategory);
|
|
}
|
|
|
|
// ==================== Products ====================
|
|
|
|
async findAll(params: ProductSearchParams): Promise<{ data: Product[]; total: number }> {
|
|
const {
|
|
tenantId,
|
|
search,
|
|
categoryId,
|
|
productType,
|
|
isActive,
|
|
isSellable,
|
|
isPurchasable,
|
|
limit = 50,
|
|
offset = 0,
|
|
} = params;
|
|
|
|
const where: FindOptionsWhere<Product>[] = [];
|
|
const baseWhere: FindOptionsWhere<Product> = { tenantId, deletedAt: IsNull() };
|
|
|
|
if (categoryId) {
|
|
baseWhere.categoryId = categoryId;
|
|
}
|
|
|
|
if (productType) {
|
|
baseWhere.productType = productType;
|
|
}
|
|
|
|
if (isActive !== undefined) {
|
|
baseWhere.isActive = isActive;
|
|
}
|
|
|
|
if (isSellable !== undefined) {
|
|
baseWhere.isSellable = isSellable;
|
|
}
|
|
|
|
if (isPurchasable !== undefined) {
|
|
baseWhere.isPurchasable = isPurchasable;
|
|
}
|
|
|
|
if (search) {
|
|
where.push(
|
|
{ ...baseWhere, name: ILike(`%${search}%`) },
|
|
{ ...baseWhere, sku: ILike(`%${search}%`) },
|
|
{ ...baseWhere, barcode: ILike(`%${search}%`) }
|
|
);
|
|
} else {
|
|
where.push(baseWhere);
|
|
}
|
|
|
|
const [data, total] = await this.productRepository.findAndCount({
|
|
where,
|
|
relations: ['category'],
|
|
take: limit,
|
|
skip: offset,
|
|
order: { name: 'ASC' },
|
|
});
|
|
|
|
return { data, total };
|
|
}
|
|
|
|
async findOne(id: string, tenantId: string): Promise<Product | null> {
|
|
return this.productRepository.findOne({
|
|
where: { id, tenantId, deletedAt: IsNull() },
|
|
relations: ['category'],
|
|
});
|
|
}
|
|
|
|
async findBySku(sku: string, tenantId: string): Promise<Product | null> {
|
|
return this.productRepository.findOne({
|
|
where: { sku, tenantId, deletedAt: IsNull() },
|
|
relations: ['category'],
|
|
});
|
|
}
|
|
|
|
async findByBarcode(barcode: string, tenantId: string): Promise<Product | null> {
|
|
return this.productRepository.findOne({
|
|
where: { barcode, tenantId, deletedAt: IsNull() },
|
|
relations: ['category'],
|
|
});
|
|
}
|
|
|
|
async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise<Product> {
|
|
// Validate unique SKU within tenant (RLS compliance)
|
|
const existingSku = await this.productRepository.findOne({
|
|
where: { sku: dto.sku, tenantId, deletedAt: IsNull() },
|
|
});
|
|
if (existingSku) {
|
|
throw new Error(`Product with SKU '${dto.sku}' already exists`);
|
|
}
|
|
|
|
// Validate unique barcode within tenant if provided (RLS compliance)
|
|
if (dto.barcode) {
|
|
const existingBarcode = await this.productRepository.findOne({
|
|
where: { barcode: dto.barcode, tenantId, deletedAt: IsNull() },
|
|
});
|
|
if (existingBarcode) {
|
|
throw new Error(`Product with barcode '${dto.barcode}' already exists`);
|
|
}
|
|
}
|
|
|
|
const product = this.productRepository.create({
|
|
...dto,
|
|
tenantId,
|
|
createdBy,
|
|
});
|
|
return this.productRepository.save(product);
|
|
}
|
|
|
|
async update(id: string, tenantId: string, dto: UpdateProductDto, updatedBy?: string): Promise<Product | null> {
|
|
const product = await this.findOne(id, tenantId);
|
|
if (!product) return null;
|
|
|
|
// Validate unique SKU within tenant if changing (RLS compliance)
|
|
if (dto.sku && dto.sku !== product.sku) {
|
|
const existingSku = await this.productRepository.findOne({
|
|
where: { sku: dto.sku, tenantId, deletedAt: IsNull() },
|
|
});
|
|
if (existingSku) {
|
|
throw new Error(`Product with SKU '${dto.sku}' already exists`);
|
|
}
|
|
}
|
|
|
|
// Validate unique barcode within tenant if changing (RLS compliance)
|
|
if (dto.barcode && dto.barcode !== product.barcode) {
|
|
const existingBarcode = await this.productRepository.findOne({
|
|
where: { barcode: dto.barcode, tenantId, deletedAt: IsNull() },
|
|
});
|
|
if (existingBarcode) {
|
|
throw new Error(`Product with barcode '${dto.barcode}' already exists`);
|
|
}
|
|
}
|
|
|
|
Object.assign(product, { ...dto, updatedBy });
|
|
return this.productRepository.save(product);
|
|
}
|
|
|
|
async delete(id: string, tenantId: string): Promise<boolean> {
|
|
const result = await this.productRepository.softDelete({ id, tenantId });
|
|
return (result.affected ?? 0) > 0;
|
|
}
|
|
|
|
async getSellableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> {
|
|
return this.findAll({ tenantId, isSellable: true, isActive: true, limit, offset });
|
|
}
|
|
|
|
async getPurchasableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> {
|
|
return this.findAll({ tenantId, isPurchasable: true, isActive: true, limit, offset });
|
|
}
|
|
|
|
// ==================== Categories ====================
|
|
|
|
async findAllCategories(params: CategorySearchParams): Promise<{ data: ProductCategory[]; total: number }> {
|
|
const { tenantId, search, parentId, isActive, limit = 50, offset = 0 } = params;
|
|
|
|
const where: FindOptionsWhere<ProductCategory> = { tenantId, deletedAt: IsNull() };
|
|
|
|
if (parentId) {
|
|
where.parentId = parentId;
|
|
}
|
|
|
|
if (isActive !== undefined) {
|
|
where.isActive = isActive;
|
|
}
|
|
|
|
if (search) {
|
|
const [data, total] = await this.categoryRepository.findAndCount({
|
|
where: [
|
|
{ ...where, name: ILike(`%${search}%`) },
|
|
{ ...where, code: ILike(`%${search}%`) },
|
|
],
|
|
take: limit,
|
|
skip: offset,
|
|
order: { name: 'ASC' },
|
|
});
|
|
return { data, total };
|
|
}
|
|
|
|
const [data, total] = await this.categoryRepository.findAndCount({
|
|
where,
|
|
take: limit,
|
|
skip: offset,
|
|
order: { sortOrder: 'ASC', name: 'ASC' },
|
|
});
|
|
|
|
return { data, total };
|
|
}
|
|
|
|
async findCategory(id: string, tenantId: string): Promise<ProductCategory | null> {
|
|
return this.categoryRepository.findOne({
|
|
where: { id, tenantId, deletedAt: IsNull() },
|
|
});
|
|
}
|
|
|
|
async createCategory(tenantId: string, dto: CreateCategoryDto, _createdBy?: string): Promise<ProductCategory> {
|
|
const category = this.categoryRepository.create({
|
|
...dto,
|
|
tenantId,
|
|
});
|
|
return this.categoryRepository.save(category);
|
|
}
|
|
|
|
async updateCategory(id: string, tenantId: string, dto: UpdateCategoryDto, _updatedBy?: string): Promise<ProductCategory | null> {
|
|
const category = await this.findCategory(id, tenantId);
|
|
if (!category) return null;
|
|
Object.assign(category, dto);
|
|
return this.categoryRepository.save(category);
|
|
}
|
|
|
|
async deleteCategory(id: string, tenantId: string): Promise<boolean> {
|
|
const result = await this.categoryRepository.softDelete({ id, tenantId });
|
|
return (result.affected ?? 0) > 0;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const productsService = new ProductsServiceClass();
|