erp-transportistas-backend-v2/src/modules/gestion-flota/products.service.ts
Adrian Flores Cortes 95c6b58449 feat: Add base modules from erp-core following SIMCO-REUSE directive
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>
2026-01-25 10:10:19 -06:00

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();