refactor: Consolidate duplicate services and normalize module structure

- Partners: Moved services to services/ directory, consolidated duplicates,
  kept singleton pattern version with ranking service
- Products: Moved service to services/, removed duplicate class-based version,
  kept singleton with deletedAt filtering
- Reports: Moved service to services/, kept raw SQL version for active controller
- Warehouses: Moved service to services/, removed duplicate class-based version,
  kept singleton with proper tenant isolation

All modules now follow consistent structure:
- services/*.service.ts for business logic
- services/index.ts for exports
- Controllers import from ./services/index.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 04:40:16 -06:00
parent 6f0548bc5b
commit 7a957a69c7
127 changed files with 1516 additions and 2836 deletions

View File

@ -11,7 +11,7 @@ jest.mock('../../config/typeorm', () => ({
}));
// Import after mocking
import { countriesService } from '../../modules/core/countries.service';
import { countriesService } from '../../modules/core/services/countries.service';
describe('CountriesService', () => {
beforeEach(() => {

View File

@ -13,7 +13,7 @@ jest.mock('../../config/typeorm', () => ({
}));
// Import after mocking
import { currenciesService } from '../../modules/core/currencies.service';
import { currenciesService } from '../../modules/core/services/currencies.service';
describe('CurrenciesService', () => {
beforeEach(() => {

View File

@ -16,7 +16,7 @@ jest.mock('../../config/typeorm', () => ({
},
}));
import { statesService } from '../../modules/core/states.service';
import { statesService } from '../../modules/core/services/states.service';
describe('StatesService', () => {
beforeEach(() => {

View File

@ -18,7 +18,7 @@ jest.mock('../../config/typeorm', () => ({
},
}));
import { uomService } from '../../modules/core/uom.service';
import { uomService } from '../../modules/core/services/uom.service';
describe('UomService', () => {
beforeEach(() => {

View File

@ -1,6 +1,6 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './apiKeys.service.js';
import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './services/apiKeys.service.js';
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
// ============================================================================

View File

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { authService } from './auth.service.js';
import { authService } from './services/auth.service.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas

View File

@ -1,8 +1,8 @@
export * from './auth.service.js';
export * from './services/auth.service.js';
export * from './auth.controller.js';
export { default as authRoutes } from './auth.routes.js';
// API Keys
export * from './apiKeys.service.js';
export * from './services/apiKeys.service.js';
export * from './apiKeys.controller.js';
export { default as apiKeysRoutes } from './apiKeys.routes.js';

View File

@ -1,7 +1,7 @@
import crypto from 'crypto';
import { query, queryOne } from '../../config/database.js';
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne } from '../../../config/database.js';
import { ValidationError, NotFoundError, UnauthorizedError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,10 +1,10 @@
import bcrypt from 'bcryptjs';
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { User, UserStatus, Role } from './entities/index.js';
import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js';
import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { User, UserStatus, Role } from '../entities/index.js';
import { tokenService, TokenPair, RequestMetadata } from './token.service.js';
import { UnauthorizedError, ValidationError, NotFoundError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface LoginDto {
email: string;

View File

@ -1,6 +1,6 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { companiesService, CreateCompanyDto, UpdateCompanyDto, CompanyFilters } from './companies.service.js';
import { companiesService, CreateCompanyDto, UpdateCompanyDto, CompanyFilters } from './services/companies.service.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas (accept both snake_case and camelCase from frontend)

View File

@ -1,3 +1,3 @@
export * from './companies.service.js';
export * from './services/companies.service.js';
export * from './companies.controller.js';
export { default as companiesRoutes } from './companies.routes.js';

View File

@ -1,8 +1,8 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Company } from '../auth/entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Company } from '../../auth/entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ===== Interfaces =====

View File

@ -1,13 +1,13 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
import { countriesService } from './countries.service.js';
import { statesService, CreateStateDto, UpdateStateDto } from './states.service.js';
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './currency-rates.service.js';
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './payment-terms.service.js';
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './discount-rules.service.js';
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './services/currencies.service.js';
import { countriesService } from './services/countries.service.js';
import { statesService, CreateStateDto, UpdateStateDto } from './services/states.service.js';
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './services/currency-rates.service.js';
import { uomService, CreateUomDto, UpdateUomDto } from './services/uom.service.js';
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './services/product-categories.service.js';
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './services/payment-terms.service.js';
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './services/discount-rules.service.js';
import { PaymentTermLineType } from './entities/payment-term.entity.js';
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';

View File

@ -1,10 +1,10 @@
export * from './currencies.service.js';
export * from './countries.service.js';
export * from './uom.service.js';
export * from './product-categories.service.js';
export * from './sequences.service.js';
export * from './payment-terms.service.js';
export * from './discount-rules.service.js';
export * from './services/currencies.service.js';
export * from './services/countries.service.js';
export * from './services/uom.service.js';
export * from './services/product-categories.service.js';
export * from './services/sequences.service.js';
export * from './services/payment-terms.service.js';
export * from './services/discount-rules.service.js';
export * from './entities/index.js';
export * from './core.controller.js';
export { default as coreRoutes } from './core.routes.js';

View File

@ -1,8 +1,8 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Country } from './entities/country.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Country } from '../entities/country.entity.js';
import { NotFoundError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
class CountriesService {
private repository: Repository<Country>;

View File

@ -1,8 +1,8 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Currency } from './entities/currency.entity.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Currency } from '../entities/currency.entity.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreateCurrencyDto {
code: string;

View File

@ -1,9 +1,9 @@
import { Repository, LessThanOrEqual } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { CurrencyRate, RateSource } from './entities/currency-rate.entity.js';
import { Currency } from './entities/currency.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { CurrencyRate, RateSource } from '../entities/currency-rate.entity.js';
import { Currency } from '../entities/currency.entity.js';
import { NotFoundError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreateCurrencyRateDto {
tenantId?: string;

View File

@ -1,13 +1,13 @@
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { AppDataSource } from '../../../config/typeorm.js';
import {
DiscountRule,
DiscountType,
DiscountAppliesTo,
DiscountCondition,
} from './entities/discount-rule.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
} from '../entities/discount-rule.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,12 +1,12 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { AppDataSource } from '../../../config/typeorm.js';
import {
PaymentTerm,
PaymentTermLine,
PaymentTermLineType,
} from './entities/payment-term.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
} from '../entities/payment-term.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,8 +1,8 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { ProductCategory } from './entities/product-category.entity.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { ProductCategory } from '../entities/product-category.entity.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreateProductCategoryDto {
name: string;

View File

@ -1,8 +1,8 @@
import { Repository, DataSource } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Sequence, ResetPeriod } from './entities/sequence.entity.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Sequence, ResetPeriod } from '../entities/sequence.entity.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,8 +1,8 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { State } from './entities/state.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { State } from '../entities/state.entity.js';
import { NotFoundError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreateStateDto {
countryId: string;

View File

@ -1,9 +1,9 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Uom, UomType } from './entities/uom.entity.js';
import { UomCategory } from './entities/uom-category.entity.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Uom, UomType } from '../entities/uom.entity.js';
import { UomCategory } from '../entities/uom-category.entity.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreateUomDto {
name: string;

View File

@ -1,8 +1,8 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './leads.service.js';
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './opportunities.service.js';
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './stages.service.js';
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './services/leads.service.js';
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './services/opportunities.service.js';
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './services/stages.service.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';

View File

@ -1,7 +1,7 @@
export * from './leads.service.js';
export * from './opportunities.service.js';
export * from './stages.service.js';
export * from './activities.service.js';
export * from './forecasting.service.js';
export * from './services/leads.service.js';
export * from './services/opportunities.service.js';
export * from './services/stages.service.js';
export * from './services/activities.service.js';
export * from './services/forecasting.service.js';
export * from './crm.controller.js';
export { default as crmRoutes } from './crm.routes.js';

View File

@ -1,6 +1,6 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne } from '../../../config/database.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,5 +1,5 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';

View File

@ -1,5 +1,5 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { LeadSource } from './leads.service.js';
export type OpportunityStatus = 'open' | 'won' | 'lost';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
// ========== LEAD STAGES ==========

View File

@ -108,7 +108,7 @@ describe('AccountsService', () => {
describe('AccountTypes Operations', () => {
it('should return all account types', async () => {
// Import dynamically to get fresh instance with mocks
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
const result = await accountsService.findAllAccountTypes();
@ -119,7 +119,7 @@ describe('AccountsService', () => {
});
it('should return account type by ID', async () => {
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
const result = await accountsService.findAccountTypeById(mockAccountTypeId);
@ -132,7 +132,7 @@ describe('AccountsService', () => {
it('should throw NotFoundError when account type not found', async () => {
mockAccountTypeRepository.findOne = jest.fn().mockResolvedValue(null);
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
await expect(
accountsService.findAccountTypeById('non-existent-id')
@ -142,7 +142,7 @@ describe('AccountsService', () => {
describe('Account CRUD Operations', () => {
it('should find all accounts with filters', async () => {
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
const result = await accountsService.findAll(mockTenantId, {
companyId: mockCompanyId,
@ -164,7 +164,7 @@ describe('AccountsService', () => {
isReconcilable: true,
};
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
// Service signature: create(dto, tenantId, userId)
const result = await accountsService.create(createDto, mockTenantId, 'mock-user-id');
@ -175,7 +175,7 @@ describe('AccountsService', () => {
});
it('should find account by ID', async () => {
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
// Service signature: findById(id, tenantId)
const result = await accountsService.findById(
@ -190,7 +190,7 @@ describe('AccountsService', () => {
it('should throw NotFoundError when account not found', async () => {
mockQueryBuilder.getOne = jest.fn().mockResolvedValue(null);
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
await expect(
accountsService.findById('non-existent-id', mockTenantId)
@ -202,7 +202,7 @@ describe('AccountsService', () => {
name: 'Updated Bank Account',
};
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
// Service signature: update(id, dto, tenantId, userId)
const result = await accountsService.update(
@ -217,7 +217,7 @@ describe('AccountsService', () => {
});
it('should soft delete an account', async () => {
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
// Service signature: delete(id, tenantId, userId)
await accountsService.delete(mockAccount.id as string, mockTenantId, 'mock-user-id');
@ -241,7 +241,7 @@ describe('AccountsService', () => {
accountTypeId: mockAccountTypeId,
};
const { accountsService } = await import('../accounts.service.js');
const { accountsService } = await import('../services/accounts.service.js');
// This should handle duplicate validation
// Exact behavior depends on service implementation
@ -258,7 +258,7 @@ describe('AccountsService', () => {
//
// mockAccountRepository.find = jest.fn().mockResolvedValue([mockAccount]);
//
// const { accountsService } = await import('../accounts.service.js');
// const { accountsService } = await import('../services/accounts.service.js');
//
// const result = await accountsService.getChartOfAccounts(
// mockTenantId,

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PaymentsService } from '../payments.service';
import { PaymentsService } from '../services/payments.service';
import { Payment, PaymentMethod, PaymentStatus } from '../entities';
import { CreatePaymentDto, UpdatePaymentDto } from '../dto';

View File

@ -1,11 +1,11 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './accounts.service.js';
import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './journals.service.js';
import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './journal-entries.service.js';
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './invoices.service.js';
import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './payments.service.js';
import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.service.js';
import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './services/accounts.service.js';
import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './services/journals.service.js';
import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './services/journal-entries.service.js';
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './services/invoices.service.js';
import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './services/payments.service.js';
import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './services/taxes.service.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';

View File

@ -1,9 +1,9 @@
export * from './accounts.service.js';
export * from './journals.service.js';
export * from './journal-entries.service.js';
export * from './invoices.service.js';
export * from './payments.service.js';
export * from './taxes.service.js';
export * from './gl-posting.service.js';
export * from './services/accounts.service.js';
export * from './services/journals.service.js';
export * from './services/journal-entries.service.js';
export * from './services/invoices.service.js';
export * from './services/payments.service.js';
export * from './services/taxes.service.js';
export * from './services/gl-posting.service.js';
export * from './financial.controller.js';
export { default as financialRoutes } from './financial.routes.js';

View File

@ -1,8 +1,8 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Account, AccountType } from './entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Account, AccountType } from '../entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ===== Interfaces =====

View File

@ -1,6 +1,6 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,8 +1,8 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { AccountMappingType } from './entities/account-mapping.entity.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne, getClient, PoolClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { AccountMappingType } from '../entities/account-mapping.entity.js';
import { sequencesService, SEQUENCE_CODES } from '../../core/services/sequences.service.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,9 +1,9 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { taxesService } from './taxes.service.js';
import { glPostingService, InvoiceForPosting } from './gl-posting.service.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
import { logger } from '../../shared/utils/logger.js';
import { sequencesService, SEQUENCE_CODES } from '../../core/services/sequences.service.js';
import { logger } from '../../../shared/utils/logger.js';
export interface InvoiceLine {
id: string;

View File

@ -1,5 +1,5 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
export type EntryStatus = 'draft' | 'posted' | 'cancelled';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';

View File

@ -1,5 +1,5 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
export interface PaymentInvoice {
invoice_id: string;

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
export interface Tax {
id: string;

View File

@ -6,7 +6,7 @@ import {
paymentMethodsService,
paymentTypesService,
withholdingTypesService,
} from './fiscal-catalogs.service.js';
} from './services/fiscal-catalogs.service.js';
import { PersonType } from './entities/fiscal-regime.entity.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';

View File

@ -1,4 +1,4 @@
export * from './entities/index.js';
export * from './fiscal-catalogs.service.js';
export * from './services/fiscal-catalogs.service.js';
export { fiscalController } from './fiscal.controller.js';
export { default as fiscalRoutes } from './fiscal.routes.js';

View File

@ -1,5 +1,5 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { AppDataSource } from '../../../config/typeorm.js';
import {
TaxCategory,
FiscalRegime,
@ -8,9 +8,9 @@ import {
PaymentType,
WithholdingType,
PersonType,
} from './entities/index.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
} from '../entities/index.js';
import { NotFoundError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ==========================================
// TAX CATEGORIES SERVICE

View File

@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { HealthService } from './health.service';
import { HealthService } from './services/health.service';
export class HealthController {
public router: Router;

View File

@ -1,6 +1,6 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { HealthService } from './health.service';
import { HealthService } from './services/health.service';
import { HealthController } from './health.controller';
export interface HealthModuleOptions {
@ -30,5 +30,5 @@ export class HealthModule {
}
}
export { HealthService } from './health.service';
export { HealthService } from './services/health.service';
export { HealthController } from './health.controller';

View File

@ -1,3 +1,3 @@
export { HealthModule, HealthModuleOptions } from './health.module';
export { HealthService, HealthStatus, HealthCheck } from './health.service';
export { HealthService, HealthStatus, HealthCheck } from './services/health.service';
export { HealthController } from './health.controller';

View File

@ -1,9 +1,9 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './employees.service.js';
import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './departments.service.js';
import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './contracts.service.js';
import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './leaves.service.js';
import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './services/employees.service.js';
import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './services/departments.service.js';
import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './services/contracts.service.js';
import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './services/leaves.service.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';

View File

@ -1,6 +1,3 @@
export * from './employees.service.js';
export * from './departments.service.js';
export * from './contracts.service.js';
export * from './leaves.service.js';
export * from './services/index.js';
export * from './hr.controller.js';
export { default as hrRoutes } from './hr.routes.js';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled';
export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
export interface Department {
id: string;

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated';

View File

@ -0,0 +1,4 @@
export * from './employees.service.js';
export * from './departments.service.js';
export * from './contracts.service.js';
export * from './leaves.service.js';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled';
export type LeaveType = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other';

View File

@ -1,11 +1,11 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './products.service.js';
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './warehouses.service.js';
import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './locations.service.js';
import { pickingsService, CreatePickingDto, PickingFilters } from './pickings.service.js';
import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './lots.service.js';
import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './adjustments.service.js';
import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './services/products.service.js';
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './services/warehouses.service.js';
import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './services/locations.service.js';
import { pickingsService, CreatePickingDto, PickingFilters } from './services/pickings.service.js';
import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './services/lots.service.js';
import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './services/adjustments.service.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';

View File

@ -1,7 +1,7 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
import { valuationService } from './valuation.service.js';
import { logger } from '../../shared/utils/logger.js';
import { logger } from '../../../shared/utils/logger.js';
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';

View File

@ -2,7 +2,25 @@ export {
InventoryService,
StockSearchParams,
MovementSearchParams,
} from './inventory.service';
} from './inventory.service.js';
// Products service
export * from './products.service.js';
// Warehouses service
export * from './warehouses.service.js';
// Locations service
export * from './locations.service.js';
// Lots service
export * from './lots.service.js';
// Pickings service
export * from './pickings.service.js';
// Adjustments service
export * from './adjustments.service.js';
// Stock reservation service for sales orders and transfers
export {
@ -11,7 +29,7 @@ export {
ReservationResult,
ReservationLineResult,
StockAvailability,
} from '../stock-reservation.service.js';
} from './stock-reservation.service.js';
// Valuation service for FIFO/Average costing
export {
@ -22,7 +40,7 @@ export {
ValuationSummary,
FifoConsumptionResult,
ProductCostResult,
} from '../valuation.service.js';
} from './valuation.service.js';
// Reorder alerts service for stock level monitoring
export {
@ -32,4 +50,4 @@ export {
StockSummary,
ReorderAlertFilters,
StockLevelFilters,
} from '../reorder-alerts.service.js';
} from './reorder-alerts.service.js';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
export type LocationType = 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
export interface Lot {
id: string;

View File

@ -1,8 +1,8 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
import { stockReservationService, ReservationLine } from './stock-reservation.service.js';
import { valuationService } from './valuation.service.js';
import { logger } from '../../shared/utils/logger.js';
import { logger } from '../../../shared/utils/logger.js';
export type PickingType = 'incoming' | 'outgoing' | 'internal';
export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled';

View File

@ -1,9 +1,9 @@
import { Repository, IsNull, ILike } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Product, ProductType, TrackingType, ValuationMethod } from './entities/product.entity.js';
import { StockQuant } from './entities/stock-quant.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Product, ProductType, TrackingType, ValuationMethod } from '../entities/product.entity.js';
import { StockQuant } from '../entities/stock-quant.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ===== Interfaces =====

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne } from '../../../config/database.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,6 +1,6 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne, getClient, PoolClient } from '../../../config/database.js';
import { ValidationError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
/**
* Stock Reservation Service

View File

@ -1,6 +1,6 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne, getClient, PoolClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,10 +1,10 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Warehouse } from '../warehouses/entities/warehouse.entity.js';
import { Location } from './entities/location.entity.js';
import { StockQuant } from './entities/stock-quant.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
import { Location } from '../entities/location.entity.js';
import { StockQuant } from '../entities/stock-quant.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ===== Interfaces =====

View File

@ -1,6 +1,6 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { valuationService, CreateValuationLayerDto } from './valuation.service.js';
import { valuationService, CreateValuationLayerDto } from './services/valuation.service.js';
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
// ============================================================================

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PartnersService } from '../partners.service';
import { partnersService } from '../services/partners.service.js';
import { Partner, PartnerStatus, PartnerType } from '../entities';
import { CreatePartnerDto, UpdatePartnerDto } from '../dto';

View File

@ -21,7 +21,7 @@ jest.mock('../../../shared/utils/logger.js', () => ({
}));
// Import after mocking
import { partnersService } from '../partners.service.js';
import { partnersService } from '../services/partners.service.js';
import { NotFoundError, ValidationError } from '../../../shared/types/index.js';
describe('PartnersService', () => {

View File

@ -1,6 +1,7 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters, PartnerType } from './partners.service.js';
import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters } from './services/index.js';
import type { PartnerType } from './services/index.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas (accept both snake_case and camelCase from frontend)

View File

@ -1,350 +0,0 @@
import { Repository, IsNull, Like } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Partner, PartnerType } from './entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
// Re-export PartnerType for controller use
export type { PartnerType };
// ===== Interfaces =====
export interface CreatePartnerDto {
code: string;
displayName: string;
legalName?: string;
partnerType?: PartnerType;
email?: string;
phone?: string;
mobile?: string;
website?: string;
taxId?: string;
taxRegime?: string;
cfdiUse?: string;
paymentTermDays?: number;
creditLimit?: number;
priceListId?: string;
discountPercent?: number;
category?: string;
tags?: string[];
notes?: string;
salesRepId?: string;
}
export interface UpdatePartnerDto {
displayName?: string;
legalName?: string | null;
partnerType?: PartnerType;
email?: string | null;
phone?: string | null;
mobile?: string | null;
website?: string | null;
taxId?: string | null;
taxRegime?: string | null;
cfdiUse?: string | null;
paymentTermDays?: number;
creditLimit?: number;
priceListId?: string | null;
discountPercent?: number;
category?: string | null;
tags?: string[];
notes?: string | null;
isActive?: boolean;
isVerified?: boolean;
salesRepId?: string | null;
}
export interface PartnerFilters {
search?: string;
partnerType?: PartnerType;
category?: string;
isActive?: boolean;
isVerified?: boolean;
page?: number;
limit?: number;
}
export interface PartnerWithRelations extends Partner {
// Add computed fields if needed
}
// ===== PartnersService Class =====
class PartnersService {
private partnerRepository: Repository<Partner>;
constructor() {
this.partnerRepository = AppDataSource.getRepository(Partner);
}
/**
* Get all partners for a tenant with filters and pagination
*/
async findAll(
tenantId: string,
filters: PartnerFilters = {}
): Promise<{ data: Partner[]; total: number }> {
try {
const { search, partnerType, category, isActive, isVerified, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.partnerRepository
.createQueryBuilder('partner')
.where('partner.tenantId = :tenantId', { tenantId })
.andWhere('partner.deletedAt IS NULL');
// Apply search filter
if (search) {
queryBuilder.andWhere(
'(partner.displayName ILIKE :search OR partner.legalName ILIKE :search OR partner.email ILIKE :search OR partner.taxId ILIKE :search OR partner.code ILIKE :search)',
{ search: `%${search}%` }
);
}
// Filter by partner type
if (partnerType !== undefined) {
queryBuilder.andWhere('partner.partnerType = :partnerType', { partnerType });
}
// Filter by category
if (category) {
queryBuilder.andWhere('partner.category = :category', { category });
}
// Filter by active status
if (isActive !== undefined) {
queryBuilder.andWhere('partner.isActive = :isActive', { isActive });
}
// Filter by verified status
if (isVerified !== undefined) {
queryBuilder.andWhere('partner.isVerified = :isVerified', { isVerified });
}
// Get total count
const total = await queryBuilder.getCount();
// Get paginated results
const data = await queryBuilder
.orderBy('partner.displayName', 'ASC')
.skip(skip)
.take(limit)
.getMany();
logger.debug('Partners retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving partners', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Get partner by ID
*/
async findById(id: string, tenantId: string): Promise<Partner> {
try {
const partner = await this.partnerRepository.findOne({
where: {
id,
tenantId,
deletedAt: IsNull(),
},
});
if (!partner) {
throw new NotFoundError('Contacto no encontrado');
}
return partner;
} catch (error) {
logger.error('Error finding partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Create a new partner
*/
async create(
dto: CreatePartnerDto,
tenantId: string,
userId: string
): Promise<Partner> {
try {
// Check if code already exists
const existing = await this.partnerRepository.findOne({
where: { code: dto.code, tenantId },
});
if (existing) {
throw new ValidationError('Ya existe un contacto con este código');
}
// Create partner - only include defined fields
const partnerData: Partial<Partner> = {
tenantId,
code: dto.code,
displayName: dto.displayName,
partnerType: dto.partnerType || 'customer',
paymentTermDays: dto.paymentTermDays ?? 0,
creditLimit: dto.creditLimit ?? 0,
discountPercent: dto.discountPercent ?? 0,
tags: dto.tags || [],
isActive: true,
isVerified: false,
createdBy: userId,
};
// Add optional fields only if defined
if (dto.legalName) partnerData.legalName = dto.legalName;
if (dto.email) partnerData.email = dto.email.toLowerCase();
if (dto.phone) partnerData.phone = dto.phone;
if (dto.mobile) partnerData.mobile = dto.mobile;
if (dto.website) partnerData.website = dto.website;
if (dto.taxId) partnerData.taxId = dto.taxId;
if (dto.taxRegime) partnerData.taxRegime = dto.taxRegime;
if (dto.cfdiUse) partnerData.cfdiUse = dto.cfdiUse;
if (dto.priceListId) partnerData.priceListId = dto.priceListId;
if (dto.category) partnerData.category = dto.category;
if (dto.notes) partnerData.notes = dto.notes;
if (dto.salesRepId) partnerData.salesRepId = dto.salesRepId;
const partner = this.partnerRepository.create(partnerData);
await this.partnerRepository.save(partner);
logger.info('Partner created', {
partnerId: partner.id,
tenantId,
code: partner.code,
displayName: partner.displayName,
createdBy: userId,
});
return partner;
} catch (error) {
logger.error('Error creating partner', {
error: (error as Error).message,
tenantId,
dto,
});
throw error;
}
}
/**
* Update a partner
*/
async update(
id: string,
dto: UpdatePartnerDto,
tenantId: string,
userId: string
): Promise<Partner> {
try {
const existing = await this.findById(id, tenantId);
// Update allowed fields
if (dto.displayName !== undefined) existing.displayName = dto.displayName;
if (dto.legalName !== undefined) existing.legalName = dto.legalName as string;
if (dto.partnerType !== undefined) existing.partnerType = dto.partnerType;
if (dto.email !== undefined) existing.email = dto.email?.toLowerCase() || null as any;
if (dto.phone !== undefined) existing.phone = dto.phone as string;
if (dto.mobile !== undefined) existing.mobile = dto.mobile as string;
if (dto.website !== undefined) existing.website = dto.website as string;
if (dto.taxId !== undefined) existing.taxId = dto.taxId as string;
if (dto.taxRegime !== undefined) existing.taxRegime = dto.taxRegime as string;
if (dto.cfdiUse !== undefined) existing.cfdiUse = dto.cfdiUse as string;
if (dto.paymentTermDays !== undefined) existing.paymentTermDays = dto.paymentTermDays;
if (dto.creditLimit !== undefined) existing.creditLimit = dto.creditLimit;
if (dto.priceListId !== undefined) existing.priceListId = dto.priceListId as string;
if (dto.discountPercent !== undefined) existing.discountPercent = dto.discountPercent;
if (dto.category !== undefined) existing.category = dto.category as string;
if (dto.tags !== undefined) existing.tags = dto.tags;
if (dto.notes !== undefined) existing.notes = dto.notes as string;
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
if (dto.isVerified !== undefined) existing.isVerified = dto.isVerified;
if (dto.salesRepId !== undefined) existing.salesRepId = dto.salesRepId as string;
existing.updatedBy = userId;
await this.partnerRepository.save(existing);
logger.info('Partner updated', {
partnerId: id,
tenantId,
updatedBy: userId,
});
return await this.findById(id, tenantId);
} catch (error) {
logger.error('Error updating partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Soft delete a partner
*/
async delete(id: string, tenantId: string, userId: string): Promise<void> {
try {
const partner = await this.findById(id, tenantId);
// Soft delete using the deletedAt column
partner.deletedAt = new Date();
partner.isActive = false;
await this.partnerRepository.save(partner);
logger.info('Partner deleted', {
partnerId: id,
tenantId,
deletedBy: userId,
});
} catch (error) {
logger.error('Error deleting partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Get customers only
*/
async findCustomers(
tenantId: string,
filters: Omit<PartnerFilters, 'partnerType'>
): Promise<{ data: Partner[]; total: number }> {
return this.findAll(tenantId, { ...filters, partnerType: 'customer' });
}
/**
* Get suppliers only
*/
async findSuppliers(
tenantId: string,
filters: Omit<PartnerFilters, 'partnerType'>
): Promise<{ data: Partner[]; total: number }> {
return this.findAll(tenantId, { ...filters, partnerType: 'supplier' });
}
}
// ===== Export Singleton Instance =====
export const partnersService = new PartnersService();

View File

@ -1,7 +1,7 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { AuthenticatedRequest } from '../../shared/types/index.js';
import { rankingService, ABCClassification } from './ranking.service.js';
import { rankingService, ABCClassification } from './services/index.js';
// ============================================================================
// VALIDATION SCHEMAS

View File

@ -1 +1,16 @@
export { PartnersService, PartnerSearchParams } from './partners.service';
export {
partnersService,
CreatePartnerDto,
UpdatePartnerDto,
PartnerFilters,
PartnerWithRelations,
} from './partners.service.js';
export type { PartnerType } from './partners.service.js';
export {
rankingService,
ABCClassification,
PartnerRanking,
RankingCalculationResult,
RankingFilters,
TopPartner,
} from './ranking.service.js';

View File

@ -1,266 +1,350 @@
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from '../entities';
import {
CreatePartnerDto,
UpdatePartnerDto,
CreatePartnerAddressDto,
CreatePartnerContactDto,
CreatePartnerBankAccountDto,
} from '../dto';
import { Repository, IsNull, Like } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import { Partner, PartnerType } from '../entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface PartnerSearchParams {
tenantId: string;
// Re-export PartnerType for controller use
export type { PartnerType };
// ===== Interfaces =====
export interface CreatePartnerDto {
code: string;
displayName: string;
legalName?: string;
partnerType?: PartnerType;
email?: string;
phone?: string;
mobile?: string;
website?: string;
taxId?: string;
taxRegime?: string;
cfdiUse?: string;
paymentTermDays?: number;
creditLimit?: number;
priceListId?: string;
discountPercent?: number;
category?: string;
tags?: string[];
notes?: string;
salesRepId?: string;
}
export interface UpdatePartnerDto {
displayName?: string;
legalName?: string | null;
partnerType?: PartnerType;
email?: string | null;
phone?: string | null;
mobile?: string | null;
website?: string | null;
taxId?: string | null;
taxRegime?: string | null;
cfdiUse?: string | null;
paymentTermDays?: number;
creditLimit?: number;
priceListId?: string | null;
discountPercent?: number;
category?: string | null;
tags?: string[];
notes?: string | null;
isActive?: boolean;
isVerified?: boolean;
salesRepId?: string | null;
}
export interface PartnerFilters {
search?: string;
partnerType?: 'customer' | 'supplier' | 'both';
partnerType?: PartnerType;
category?: string;
isActive?: boolean;
salesRepId?: string;
isVerified?: boolean;
page?: number;
limit?: number;
offset?: number;
}
export class PartnersService {
constructor(
private readonly partnerRepository: Repository<Partner>,
private readonly addressRepository: Repository<PartnerAddress>,
private readonly contactRepository: Repository<PartnerContact>,
private readonly bankAccountRepository: Repository<PartnerBankAccount>
) {}
export interface PartnerWithRelations extends Partner {
// Add computed fields if needed
}
// ==================== Partners ====================
// ===== PartnersService Class =====
async findAll(params: PartnerSearchParams): Promise<{ data: Partner[]; total: number }> {
const {
tenantId,
search,
partnerType,
category,
isActive,
salesRepId,
limit = 50,
offset = 0,
} = params;
class PartnersService {
private partnerRepository: Repository<Partner>;
const where: FindOptionsWhere<Partner>[] = [];
const baseWhere: FindOptionsWhere<Partner> = { tenantId };
if (partnerType) {
baseWhere.partnerType = partnerType;
}
if (category) {
baseWhere.category = category;
}
if (isActive !== undefined) {
baseWhere.isActive = isActive;
}
if (salesRepId) {
baseWhere.salesRepId = salesRepId;
}
if (search) {
where.push(
{ ...baseWhere, displayName: ILike(`%${search}%`) },
{ ...baseWhere, legalName: ILike(`%${search}%`) },
{ ...baseWhere, code: ILike(`%${search}%`) },
{ ...baseWhere, taxId: ILike(`%${search}%`) },
{ ...baseWhere, email: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const [data, total] = await this.partnerRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { displayName: 'ASC' },
});
return { data, total };
constructor() {
this.partnerRepository = AppDataSource.getRepository(Partner);
}
async findOne(id: string, tenantId: string): Promise<Partner | null> {
return this.partnerRepository.findOne({ where: { id, tenantId } });
}
/**
* Get all partners for a tenant with filters and pagination
*/
async findAll(
tenantId: string,
filters: PartnerFilters = {}
): Promise<{ data: Partner[]; total: number }> {
try {
const { search, partnerType, category, isActive, isVerified, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
async findByCode(code: string, tenantId: string): Promise<Partner | null> {
return this.partnerRepository.findOne({ where: { code, tenantId } });
}
const queryBuilder = this.partnerRepository
.createQueryBuilder('partner')
.where('partner.tenantId = :tenantId', { tenantId })
.andWhere('partner.deletedAt IS NULL');
async findByTaxId(taxId: string, tenantId: string): Promise<Partner | null> {
return this.partnerRepository.findOne({ where: { taxId, tenantId } });
}
async create(tenantId: string, dto: CreatePartnerDto, createdBy?: string): Promise<Partner> {
// Check for existing code
const existingCode = await this.findByCode(dto.code, tenantId);
if (existingCode) {
throw new Error('A partner with this code already exists');
}
// Check for existing tax ID
if (dto.taxId) {
const existingTaxId = await this.findByTaxId(dto.taxId, tenantId);
if (existingTaxId) {
throw new Error('A partner with this tax ID already exists');
// Apply search filter
if (search) {
queryBuilder.andWhere(
'(partner.displayName ILIKE :search OR partner.legalName ILIKE :search OR partner.email ILIKE :search OR partner.taxId ILIKE :search OR partner.code ILIKE :search)',
{ search: `%${search}%` }
);
}
// Filter by partner type
if (partnerType !== undefined) {
queryBuilder.andWhere('partner.partnerType = :partnerType', { partnerType });
}
// Filter by category
if (category) {
queryBuilder.andWhere('partner.category = :category', { category });
}
// Filter by active status
if (isActive !== undefined) {
queryBuilder.andWhere('partner.isActive = :isActive', { isActive });
}
// Filter by verified status
if (isVerified !== undefined) {
queryBuilder.andWhere('partner.isVerified = :isVerified', { isVerified });
}
// Get total count
const total = await queryBuilder.getCount();
// Get paginated results
const data = await queryBuilder
.orderBy('partner.displayName', 'ASC')
.skip(skip)
.take(limit)
.getMany();
logger.debug('Partners retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving partners', {
error: (error as Error).message,
tenantId,
});
throw error;
}
const partner = this.partnerRepository.create({
...dto,
tenantId,
createdBy,
});
return this.partnerRepository.save(partner);
}
/**
* Get partner by ID
*/
async findById(id: string, tenantId: string): Promise<Partner> {
try {
const partner = await this.partnerRepository.findOne({
where: {
id,
tenantId,
deletedAt: IsNull(),
},
});
if (!partner) {
throw new NotFoundError('Contacto no encontrado');
}
return partner;
} catch (error) {
logger.error('Error finding partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Create a new partner
*/
async create(
dto: CreatePartnerDto,
tenantId: string,
userId: string
): Promise<Partner> {
try {
// Check if code already exists
const existing = await this.partnerRepository.findOne({
where: { code: dto.code, tenantId },
});
if (existing) {
throw new ValidationError('Ya existe un contacto con este código');
}
// Create partner - only include defined fields
const partnerData: Partial<Partner> = {
tenantId,
code: dto.code,
displayName: dto.displayName,
partnerType: dto.partnerType || 'customer',
paymentTermDays: dto.paymentTermDays ?? 0,
creditLimit: dto.creditLimit ?? 0,
discountPercent: dto.discountPercent ?? 0,
tags: dto.tags || [],
isActive: true,
isVerified: false,
createdBy: userId,
};
// Add optional fields only if defined
if (dto.legalName) partnerData.legalName = dto.legalName;
if (dto.email) partnerData.email = dto.email.toLowerCase();
if (dto.phone) partnerData.phone = dto.phone;
if (dto.mobile) partnerData.mobile = dto.mobile;
if (dto.website) partnerData.website = dto.website;
if (dto.taxId) partnerData.taxId = dto.taxId;
if (dto.taxRegime) partnerData.taxRegime = dto.taxRegime;
if (dto.cfdiUse) partnerData.cfdiUse = dto.cfdiUse;
if (dto.priceListId) partnerData.priceListId = dto.priceListId;
if (dto.category) partnerData.category = dto.category;
if (dto.notes) partnerData.notes = dto.notes;
if (dto.salesRepId) partnerData.salesRepId = dto.salesRepId;
const partner = this.partnerRepository.create(partnerData);
await this.partnerRepository.save(partner);
logger.info('Partner created', {
partnerId: partner.id,
tenantId,
code: partner.code,
displayName: partner.displayName,
createdBy: userId,
});
return partner;
} catch (error) {
logger.error('Error creating partner', {
error: (error as Error).message,
tenantId,
dto,
});
throw error;
}
}
/**
* Update a partner
*/
async update(
id: string,
tenantId: string,
dto: UpdatePartnerDto,
updatedBy?: string
): Promise<Partner | null> {
const partner = await this.findOne(id, tenantId);
if (!partner) return null;
tenantId: string,
userId: string
): Promise<Partner> {
try {
const existing = await this.findById(id, tenantId);
// If changing code, check for duplicates
if (dto.code && dto.code !== partner.code) {
const existing = await this.findByCode(dto.code, tenantId);
if (existing) {
throw new Error('A partner with this code already exists');
}
// Update allowed fields
if (dto.displayName !== undefined) existing.displayName = dto.displayName;
if (dto.legalName !== undefined) existing.legalName = dto.legalName as string;
if (dto.partnerType !== undefined) existing.partnerType = dto.partnerType;
if (dto.email !== undefined) existing.email = dto.email?.toLowerCase() || null as any;
if (dto.phone !== undefined) existing.phone = dto.phone as string;
if (dto.mobile !== undefined) existing.mobile = dto.mobile as string;
if (dto.website !== undefined) existing.website = dto.website as string;
if (dto.taxId !== undefined) existing.taxId = dto.taxId as string;
if (dto.taxRegime !== undefined) existing.taxRegime = dto.taxRegime as string;
if (dto.cfdiUse !== undefined) existing.cfdiUse = dto.cfdiUse as string;
if (dto.paymentTermDays !== undefined) existing.paymentTermDays = dto.paymentTermDays;
if (dto.creditLimit !== undefined) existing.creditLimit = dto.creditLimit;
if (dto.priceListId !== undefined) existing.priceListId = dto.priceListId as string;
if (dto.discountPercent !== undefined) existing.discountPercent = dto.discountPercent;
if (dto.category !== undefined) existing.category = dto.category as string;
if (dto.tags !== undefined) existing.tags = dto.tags;
if (dto.notes !== undefined) existing.notes = dto.notes as string;
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
if (dto.isVerified !== undefined) existing.isVerified = dto.isVerified;
if (dto.salesRepId !== undefined) existing.salesRepId = dto.salesRepId as string;
existing.updatedBy = userId;
await this.partnerRepository.save(existing);
logger.info('Partner updated', {
partnerId: id,
tenantId,
updatedBy: userId,
});
return await this.findById(id, tenantId);
} catch (error) {
logger.error('Error updating partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
// If changing tax ID, check for duplicates
if (dto.taxId && dto.taxId !== partner.taxId) {
const existing = await this.findByTaxId(dto.taxId, tenantId);
if (existing && existing.id !== id) {
throw new Error('A partner with this tax ID already exists');
}
/**
* Soft delete a partner
*/
async delete(id: string, tenantId: string, userId: string): Promise<void> {
try {
const partner = await this.findById(id, tenantId);
// Soft delete using the deletedAt column
partner.deletedAt = new Date();
partner.isActive = false;
await this.partnerRepository.save(partner);
logger.info('Partner deleted', {
partnerId: id,
tenantId,
deletedBy: userId,
});
} catch (error) {
logger.error('Error deleting partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
Object.assign(partner, {
...dto,
updatedBy,
});
return this.partnerRepository.save(partner);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const partner = await this.findOne(id, tenantId);
if (!partner) return false;
const result = await this.partnerRepository.softDelete(id);
return (result.affected ?? 0) > 0;
/**
* Get customers only
*/
async findCustomers(
tenantId: string,
filters: Omit<PartnerFilters, 'partnerType'>
): Promise<{ data: Partner[]; total: number }> {
return this.findAll(tenantId, { ...filters, partnerType: 'customer' });
}
async getCustomers(tenantId: string): Promise<Partner[]> {
return this.partnerRepository.find({
where: [
{ tenantId, partnerType: 'customer', isActive: true },
{ tenantId, partnerType: 'both', isActive: true },
],
order: { displayName: 'ASC' },
});
}
async getSuppliers(tenantId: string): Promise<Partner[]> {
return this.partnerRepository.find({
where: [
{ tenantId, partnerType: 'supplier', isActive: true },
{ tenantId, partnerType: 'both', isActive: true },
],
order: { displayName: 'ASC' },
});
}
// ==================== Addresses ====================
async getAddresses(partnerId: string): Promise<PartnerAddress[]> {
return this.addressRepository.find({
where: { partnerId },
order: { isDefault: 'DESC', addressType: 'ASC' },
});
}
async createAddress(dto: CreatePartnerAddressDto): Promise<PartnerAddress> {
// If setting as default, unset other defaults of same type
if (dto.isDefault) {
await this.addressRepository.update(
{ partnerId: dto.partnerId, addressType: dto.addressType },
{ isDefault: false }
);
}
const address = this.addressRepository.create(dto);
return this.addressRepository.save(address);
}
async deleteAddress(id: string): Promise<boolean> {
const result = await this.addressRepository.delete(id);
return (result.affected ?? 0) > 0;
}
// ==================== Contacts ====================
async getContacts(partnerId: string): Promise<PartnerContact[]> {
return this.contactRepository.find({
where: { partnerId },
order: { isPrimary: 'DESC', fullName: 'ASC' },
});
}
async createContact(dto: CreatePartnerContactDto): Promise<PartnerContact> {
// If setting as primary, unset other primaries
if (dto.isPrimary) {
await this.contactRepository.update({ partnerId: dto.partnerId }, { isPrimary: false });
}
const contact = this.contactRepository.create(dto);
return this.contactRepository.save(contact);
}
async deleteContact(id: string): Promise<boolean> {
const result = await this.contactRepository.delete(id);
return (result.affected ?? 0) > 0;
}
// ==================== Bank Accounts ====================
async getBankAccounts(partnerId: string): Promise<PartnerBankAccount[]> {
return this.bankAccountRepository.find({
where: { partnerId },
order: { isDefault: 'DESC', bankName: 'ASC' },
});
}
async createBankAccount(dto: CreatePartnerBankAccountDto): Promise<PartnerBankAccount> {
// If setting as default, unset other defaults
if (dto.isDefault) {
await this.bankAccountRepository.update({ partnerId: dto.partnerId }, { isDefault: false });
}
const bankAccount = this.bankAccountRepository.create(dto);
return this.bankAccountRepository.save(bankAccount);
}
async deleteBankAccount(id: string): Promise<boolean> {
const result = await this.bankAccountRepository.delete(id);
return (result.affected ?? 0) > 0;
}
async verifyBankAccount(id: string): Promise<PartnerBankAccount | null> {
const bankAccount = await this.bankAccountRepository.findOne({ where: { id } });
if (!bankAccount) return null;
bankAccount.isVerified = true;
bankAccount.verifiedAt = new Date();
return this.bankAccountRepository.save(bankAccount);
/**
* Get suppliers only
*/
async findSuppliers(
tenantId: string,
filters: Omit<PartnerFilters, 'partnerType'>
): Promise<{ data: Partner[]; total: number }> {
return this.findAll(tenantId, { ...filters, partnerType: 'supplier' });
}
}
// ===== Export Singleton Instance =====
export const partnersService = new PartnersService();

View File

@ -1,8 +1,8 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Partner } from './entities/index.js';
import { NotFoundError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
import { AppDataSource } from '../../../config/typeorm.js';
import { Partner } from '../entities/index.js';
import { NotFoundError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -16,7 +16,7 @@ jest.mock('../../../config/typeorm.js', () => ({
}));
// Import after mocking
import { productsService } from '../products.service.js';
import { productsService } from '../services/products.service.js';
describe('ProductsService', () => {
const tenantId = 'test-tenant-uuid';

View File

@ -1,6 +1,6 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { productsService, CreateProductDto, UpdateProductDto, CreateCategoryDto, UpdateCategoryDto } from './products.service.js';
import { productsService, CreateProductDto, UpdateProductDto, CreateCategoryDto, UpdateCategoryDto } from './services/index.js';
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
// Validation schemas

View File

@ -1,300 +0,0 @@
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();

View File

@ -1 +1,9 @@
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
export {
productsService,
ProductSearchParams,
CategorySearchParams,
CreateProductDto,
UpdateProductDto,
CreateCategoryDto,
UpdateCategoryDto,
} from './products.service.js';

View File

@ -1,11 +1,7 @@
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { Product, ProductCategory } from '../entities';
import {
CreateProductDto,
UpdateProductDto,
CreateProductCategoryDto,
UpdateProductCategoryDto,
} from '../dto';
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;
@ -28,11 +24,64 @@ export interface CategorySearchParams {
offset?: number;
}
export class ProductsService {
constructor(
private readonly productRepository: Repository<Product>,
private readonly categoryRepository: Repository<ProductCategory>
) {}
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 ====================
@ -50,7 +99,7 @@ export class ProductsService {
} = params;
const where: FindOptionsWhere<Product>[] = [];
const baseWhere: FindOptionsWhere<Product> = { tenantId };
const baseWhere: FindOptionsWhere<Product> = { tenantId, deletedAt: IsNull() };
if (categoryId) {
baseWhere.categoryId = categoryId;
@ -76,8 +125,7 @@ export class ProductsService {
where.push(
{ ...baseWhere, name: ILike(`%${search}%`) },
{ ...baseWhere, sku: ILike(`%${search}%`) },
{ ...baseWhere, barcode: ILike(`%${search}%`) },
{ ...baseWhere, description: ILike(`%${search}%`) }
{ ...baseWhere, barcode: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
@ -96,37 +144,41 @@ export class ProductsService {
async findOne(id: string, tenantId: string): Promise<Product | null> {
return this.productRepository.findOne({
where: { id, tenantId },
where: { id, tenantId, deletedAt: IsNull() },
relations: ['category'],
});
}
async findBySku(sku: string, tenantId: string): Promise<Product | null> {
return this.productRepository.findOne({
where: { sku, tenantId },
where: { sku, tenantId, deletedAt: IsNull() },
relations: ['category'],
});
}
async findByBarcode(barcode: string, tenantId: string): Promise<Product | null> {
return this.productRepository.findOne({
where: { barcode, tenantId },
where: { barcode, tenantId, deletedAt: IsNull() },
relations: ['category'],
});
}
async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise<Product> {
// Check for existing SKU
const existingSku = await this.findBySku(dto.sku, tenantId);
// 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('A product with this SKU already exists');
throw new Error(`Product with SKU '${dto.sku}' already exists`);
}
// Check for existing barcode
// Validate unique barcode within tenant if provided (RLS compliance)
if (dto.barcode) {
const existingBarcode = await this.findByBarcode(dto.barcode, tenantId);
const existingBarcode = await this.productRepository.findOne({
where: { barcode: dto.barcode, tenantId, deletedAt: IsNull() },
});
if (existingBarcode) {
throw new Error('A product with this barcode already exists');
throw new Error(`Product with barcode '${dto.barcode}' already exists`);
}
}
@ -135,92 +187,76 @@ export class ProductsService {
tenantId,
createdBy,
});
return this.productRepository.save(product);
}
async update(
id: string,
tenantId: string,
dto: UpdateProductDto,
updatedBy?: string
): Promise<Product | null> {
async update(id: string, tenantId: string, dto: UpdateProductDto, updatedBy?: string): Promise<Product | null> {
const product = await this.findOne(id, tenantId);
if (!product) return null;
// If changing SKU, check for duplicates
// Validate unique SKU within tenant if changing (RLS compliance)
if (dto.sku && dto.sku !== product.sku) {
const existing = await this.findBySku(dto.sku, tenantId);
if (existing) {
throw new Error('A product with this SKU already exists');
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`);
}
}
// If changing barcode, check for duplicates
// Validate unique barcode within tenant if changing (RLS compliance)
if (dto.barcode && dto.barcode !== product.barcode) {
const existing = await this.findByBarcode(dto.barcode, tenantId);
if (existing && existing.id !== id) {
throw new Error('A product with this barcode already exists');
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,
});
Object.assign(product, { ...dto, updatedBy });
return this.productRepository.save(product);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const product = await this.findOne(id, tenantId);
if (!product) return false;
const result = await this.productRepository.softDelete(id);
const result = await this.productRepository.softDelete({ id, tenantId });
return (result.affected ?? 0) > 0;
}
async getSellableProducts(tenantId: string): Promise<Product[]> {
return this.productRepository.find({
where: { tenantId, isActive: true, isSellable: true },
relations: ['category'],
order: { name: 'ASC' },
});
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): Promise<Product[]> {
return this.productRepository.find({
where: { tenantId, isActive: true, isPurchasable: true },
relations: ['category'],
order: { name: 'ASC' },
});
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 = 100, offset = 0 } = params;
async findAllCategories(params: CategorySearchParams): Promise<{ data: ProductCategory[]; total: number }> {
const { tenantId, search, parentId, isActive, limit = 50, offset = 0 } = params;
const where: FindOptionsWhere<ProductCategory>[] = [];
const baseWhere: FindOptionsWhere<ProductCategory> = { tenantId };
const where: FindOptionsWhere<ProductCategory> = { tenantId, deletedAt: IsNull() };
if (parentId !== undefined) {
baseWhere.parentId = parentId || undefined;
if (parentId) {
where.parentId = parentId;
}
if (isActive !== undefined) {
baseWhere.isActive = isActive;
where.isActive = isActive;
}
if (search) {
where.push(
{ ...baseWhere, name: ILike(`%${search}%`) },
{ ...baseWhere, code: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
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({
@ -234,95 +270,31 @@ export class ProductsService {
}
async findCategory(id: string, tenantId: string): Promise<ProductCategory | null> {
return this.categoryRepository.findOne({ where: { id, tenantId } });
return this.categoryRepository.findOne({
where: { id, tenantId, deletedAt: IsNull() },
});
}
async findCategoryByCode(code: string, tenantId: string): Promise<ProductCategory | null> {
return this.categoryRepository.findOne({ where: { code, tenantId } });
}
async createCategory(
tenantId: string,
dto: CreateProductCategoryDto
): Promise<ProductCategory> {
// Check for existing code
const existingCode = await this.findCategoryByCode(dto.code, tenantId);
if (existingCode) {
throw new Error('A category with this code already exists');
}
// Calculate hierarchy if parent exists
let hierarchyPath = `/${dto.code}`;
let hierarchyLevel = 0;
if (dto.parentId) {
const parent = await this.findCategory(dto.parentId, tenantId);
if (parent) {
hierarchyPath = `${parent.hierarchyPath}/${dto.code}`;
hierarchyLevel = parent.hierarchyLevel + 1;
}
}
async createCategory(tenantId: string, dto: CreateCategoryDto, _createdBy?: string): Promise<ProductCategory> {
const category = this.categoryRepository.create({
...dto,
tenantId,
hierarchyPath,
hierarchyLevel,
});
return this.categoryRepository.save(category);
}
async updateCategory(
id: string,
tenantId: string,
dto: UpdateProductCategoryDto
): Promise<ProductCategory | null> {
async updateCategory(id: string, tenantId: string, dto: UpdateCategoryDto, _updatedBy?: string): Promise<ProductCategory | null> {
const category = await this.findCategory(id, tenantId);
if (!category) return null;
// If changing code, check for duplicates
if (dto.code && dto.code !== category.code) {
const existing = await this.findCategoryByCode(dto.code, tenantId);
if (existing) {
throw new Error('A category with this code already exists');
}
}
Object.assign(category, dto);
return this.categoryRepository.save(category);
}
async deleteCategory(id: string, tenantId: string): Promise<boolean> {
const category = await this.findCategory(id, tenantId);
if (!category) return false;
// Check if category has children
const children = await this.categoryRepository.findOne({
where: { parentId: id, tenantId },
});
if (children) {
throw new Error('Cannot delete category with children');
}
// Check if category has products
const products = await this.productRepository.findOne({
where: { categoryId: id, tenantId },
});
if (products) {
throw new Error('Cannot delete category with products');
}
const result = await this.categoryRepository.softDelete(id);
const result = await this.categoryRepository.softDelete({ id, tenantId });
return (result.affected ?? 0) > 0;
}
async getCategoryTree(tenantId: string): Promise<ProductCategory[]> {
const categories = await this.categoryRepository.find({
where: { tenantId, isActive: true },
order: { hierarchyPath: 'ASC', sortOrder: 'ASC' },
});
return categories;
}
}
// Export singleton instance
export const productsService = new ProductsServiceClass();

View File

@ -1,8 +1,8 @@
export * from './projects.service.js';
export * from './tasks.service.js';
export * from './timesheets.service.js';
export * from './billing.service.js';
export * from './hr-integration.service.js';
export * from './services/projects.service.js';
export * from './services/tasks.service.js';
export * from './services/timesheets.service.js';
export * from './services/billing.service.js';
export * from './services/hr-integration.service.js';
export * from './projects.controller.js';
export { default as projectsRoutes } from './projects.routes.js';

View File

@ -1,8 +1,8 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { projectsService, CreateProjectDto, UpdateProjectDto, ProjectFilters } from './projects.service.js';
import { tasksService, CreateTaskDto, UpdateTaskDto, TaskFilters } from './tasks.service.js';
import { timesheetsService, CreateTimesheetDto, UpdateTimesheetDto, TimesheetFilters } from './timesheets.service.js';
import { projectsService, CreateProjectDto, UpdateProjectDto, ProjectFilters } from './services/projects.service.js';
import { tasksService, CreateTaskDto, UpdateTaskDto, TaskFilters } from './services/tasks.service.js';
import { timesheetsService, CreateTimesheetDto, UpdateTimesheetDto, TimesheetFilters } from './services/timesheets.service.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';

View File

@ -1,6 +1,6 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -1,6 +1,6 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import { logger } from '../../../shared/utils/logger.js';
// ============================================================================
// TYPES

View File

@ -0,0 +1,6 @@
// Projects services barrel export
export * from './projects.service.js';
export * from './tasks.service.js';
export * from './timesheets.service.js';
export * from './billing.service.js';
export * from './hr-integration.service.js';

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
export interface Project {
id: string;

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
export interface Task {
id: string;

View File

@ -1,5 +1,5 @@
import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
export interface Timesheet {
id: string;

View File

@ -1,7 +1,7 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './purchases.service.js';
import { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './rfqs.service.js';
import { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './services/purchases.service.js';
import { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './services/rfqs.service.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';

View File

@ -5,6 +5,10 @@ import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto';
// Export 3-Way Matching Service
export { ThreeWayMatchingService, MatchingSearchParams, InvoiceData, InvoiceLineData } from './three-way-matching.service';
// Export SQL-based services (used by controllers)
export { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './purchases.service.js';
export { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './rfqs.service.js';
export interface PurchaseSearchParams {
tenantId: string;
supplierId?: string;

View File

@ -1,7 +1,7 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
import { logger } from '../../shared/utils/logger.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
import { sequencesService, SEQUENCE_CODES } from '../../core/services/sequences.service.js';
import { logger } from '../../../shared/utils/logger.js';
export type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled';

View File

@ -1,5 +1,5 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
export type RfqStatus = 'draft' | 'sent' | 'responded' | 'accepted' | 'rejected' | 'cancelled';

View File

@ -1,7 +1,7 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { AuthenticatedRequest } from '../../shared/types/index.js';
import { reportsService } from './reports.service.js';
import { reportsService } from './services/index.js';
// ============================================================================
// VALIDATION SCHEMAS

View File

@ -1,580 +0,0 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom';
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook';
export interface ReportDefinition {
id: string;
tenant_id: string;
code: string;
name: string;
description: string | null;
report_type: ReportType;
category: string | null;
base_query: string | null;
query_function: string | null;
parameters_schema: Record<string, any>;
columns_config: any[];
grouping_options: string[];
totals_config: Record<string, any>;
export_formats: string[];
pdf_template: string | null;
xlsx_template: string | null;
is_system: boolean;
is_active: boolean;
required_permissions: string[];
version: number;
created_at: Date;
}
export interface ReportExecution {
id: string;
tenant_id: string;
definition_id: string;
definition_name?: string;
definition_code?: string;
parameters: Record<string, any>;
status: ExecutionStatus;
started_at: Date | null;
completed_at: Date | null;
execution_time_ms: number | null;
row_count: number | null;
result_data: any;
result_summary: Record<string, any> | null;
output_files: any[];
error_message: string | null;
error_details: Record<string, any> | null;
requested_by: string;
requested_by_name?: string;
created_at: Date;
}
export interface ReportSchedule {
id: string;
tenant_id: string;
definition_id: string;
definition_name?: string;
company_id: string | null;
name: string;
default_parameters: Record<string, any>;
cron_expression: string;
timezone: string;
is_active: boolean;
last_execution_id: string | null;
last_run_at: Date | null;
next_run_at: Date | null;
delivery_method: DeliveryMethod;
delivery_config: Record<string, any>;
created_at: Date;
}
export interface CreateReportDefinitionDto {
code: string;
name: string;
description?: string;
report_type?: ReportType;
category?: string;
base_query?: string;
query_function?: string;
parameters_schema?: Record<string, any>;
columns_config?: any[];
export_formats?: string[];
required_permissions?: string[];
}
export interface ExecuteReportDto {
definition_id: string;
parameters: Record<string, any>;
}
export interface ReportFilters {
report_type?: ReportType;
category?: string;
is_system?: boolean;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// SERVICE
// ============================================================================
class ReportsService {
// ==================== DEFINITIONS ====================
async findAllDefinitions(
tenantId: string,
filters: ReportFilters = {}
): Promise<{ data: ReportDefinition[]; total: number }> {
const { report_type, category, is_system, search, page = 1, limit = 20 } = filters;
const conditions: string[] = ['tenant_id = $1', 'is_active = true'];
const params: any[] = [tenantId];
let idx = 2;
if (report_type) {
conditions.push(`report_type = $${idx++}`);
params.push(report_type);
}
if (category) {
conditions.push(`category = $${idx++}`);
params.push(category);
}
if (is_system !== undefined) {
conditions.push(`is_system = $${idx++}`);
params.push(is_system);
}
if (search) {
conditions.push(`(name ILIKE $${idx} OR code ILIKE $${idx} OR description ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const whereClause = conditions.join(' AND ');
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM reports.report_definitions WHERE ${whereClause}`,
params
);
const offset = (page - 1) * limit;
params.push(limit, offset);
const data = await query<ReportDefinition>(
`SELECT * FROM reports.report_definitions
WHERE ${whereClause}
ORDER BY is_system DESC, name ASC
LIMIT $${idx} OFFSET $${idx + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findDefinitionById(id: string, tenantId: string): Promise<ReportDefinition> {
const definition = await queryOne<ReportDefinition>(
`SELECT * FROM reports.report_definitions WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!definition) {
throw new NotFoundError('Definición de reporte no encontrada');
}
return definition;
}
async findDefinitionByCode(code: string, tenantId: string): Promise<ReportDefinition | null> {
return queryOne<ReportDefinition>(
`SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`,
[code, tenantId]
);
}
async createDefinition(
dto: CreateReportDefinitionDto,
tenantId: string,
userId: string
): Promise<ReportDefinition> {
const definition = await queryOne<ReportDefinition>(
`INSERT INTO reports.report_definitions (
tenant_id, code, name, description, report_type, category,
base_query, query_function, parameters_schema, columns_config,
export_formats, required_permissions, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
tenantId,
dto.code,
dto.name,
dto.description || null,
dto.report_type || 'custom',
dto.category || null,
dto.base_query || null,
dto.query_function || null,
JSON.stringify(dto.parameters_schema || {}),
JSON.stringify(dto.columns_config || []),
JSON.stringify(dto.export_formats || ['pdf', 'xlsx', 'csv']),
JSON.stringify(dto.required_permissions || []),
userId,
]
);
logger.info('Report definition created', { definitionId: definition?.id, code: dto.code });
return definition!;
}
// ==================== EXECUTIONS ====================
async executeReport(
dto: ExecuteReportDto,
tenantId: string,
userId: string
): Promise<ReportExecution> {
const definition = await this.findDefinitionById(dto.definition_id, tenantId);
// Validar parámetros contra el schema
this.validateParameters(dto.parameters, definition.parameters_schema);
// Crear registro de ejecución
const execution = await queryOne<ReportExecution>(
`INSERT INTO reports.report_executions (
tenant_id, definition_id, parameters, status, requested_by
) VALUES ($1, $2, $3, 'pending', $4)
RETURNING *`,
[tenantId, dto.definition_id, JSON.stringify(dto.parameters), userId]
);
// Ejecutar el reporte de forma asíncrona
this.runReportExecution(execution!.id, definition, dto.parameters, tenantId)
.catch(err => logger.error('Report execution failed', { executionId: execution!.id, error: err }));
return execution!;
}
private async runReportExecution(
executionId: string,
definition: ReportDefinition,
parameters: Record<string, any>,
tenantId: string
): Promise<void> {
const startTime = Date.now();
try {
// Marcar como ejecutando
await query(
`UPDATE reports.report_executions SET status = 'running', started_at = NOW() WHERE id = $1`,
[executionId]
);
let resultData: any;
let rowCount = 0;
if (definition.query_function) {
// Ejecutar función PostgreSQL
const funcParams = this.buildFunctionParams(definition.query_function, parameters, tenantId);
resultData = await query(
`SELECT * FROM ${definition.query_function}(${funcParams.placeholders})`,
funcParams.values
);
rowCount = resultData.length;
} else if (definition.base_query) {
// Ejecutar query base con parámetros sustituidos
// IMPORTANTE: Sanitizar los parámetros para evitar SQL injection
const sanitizedQuery = this.buildSafeQuery(definition.base_query, parameters, tenantId);
resultData = await query(sanitizedQuery.sql, sanitizedQuery.values);
rowCount = resultData.length;
} else {
throw new Error('La definición del reporte no tiene query ni función definida');
}
const executionTime = Date.now() - startTime;
// Calcular resumen si hay config de totales
const resultSummary = this.calculateSummary(resultData, definition.totals_config);
// Actualizar con resultados
await query(
`UPDATE reports.report_executions
SET status = 'completed',
completed_at = NOW(),
execution_time_ms = $2,
row_count = $3,
result_data = $4,
result_summary = $5
WHERE id = $1`,
[executionId, executionTime, rowCount, JSON.stringify(resultData), JSON.stringify(resultSummary)]
);
logger.info('Report execution completed', { executionId, rowCount, executionTime });
} catch (error: any) {
const executionTime = Date.now() - startTime;
await query(
`UPDATE reports.report_executions
SET status = 'failed',
completed_at = NOW(),
execution_time_ms = $2,
error_message = $3,
error_details = $4
WHERE id = $1`,
[
executionId,
executionTime,
error.message,
JSON.stringify({ stack: error.stack }),
]
);
logger.error('Report execution failed', { executionId, error: error.message });
}
}
private buildFunctionParams(
functionName: string,
parameters: Record<string, any>,
tenantId: string
): { placeholders: string; values: any[] } {
// Construir parámetros para funciones conocidas
const values: any[] = [tenantId];
let idx = 2;
if (functionName.includes('trial_balance')) {
values.push(
parameters.company_id || null,
parameters.date_from,
parameters.date_to,
parameters.include_zero || false
);
return { placeholders: '$1, $2, $3, $4, $5', values };
}
if (functionName.includes('general_ledger')) {
values.push(
parameters.company_id || null,
parameters.account_id,
parameters.date_from,
parameters.date_to
);
return { placeholders: '$1, $2, $3, $4, $5', values };
}
// Default: solo tenant_id
return { placeholders: '$1', values };
}
private buildSafeQuery(
baseQuery: string,
parameters: Record<string, any>,
tenantId: string
): { sql: string; values: any[] } {
// Reemplazar placeholders de forma segura
let sql = baseQuery;
const values: any[] = [tenantId];
let idx = 2;
// Reemplazar {{tenant_id}} con $1
sql = sql.replace(/\{\{tenant_id\}\}/g, '$1');
// Reemplazar otros parámetros
for (const [key, value] of Object.entries(parameters)) {
const placeholder = `{{${key}}}`;
if (sql.includes(placeholder)) {
sql = sql.replace(new RegExp(placeholder, 'g'), `$${idx}`);
values.push(value);
idx++;
}
}
return { sql, values };
}
private calculateSummary(data: any[], totalsConfig: Record<string, any>): Record<string, any> {
if (!totalsConfig.show_totals || !totalsConfig.total_columns) {
return {};
}
const summary: Record<string, number> = {};
for (const column of totalsConfig.total_columns) {
summary[column] = data.reduce((sum, row) => sum + (parseFloat(row[column]) || 0), 0);
}
return summary;
}
private validateParameters(params: Record<string, any>, schema: Record<string, any>): void {
for (const [key, config] of Object.entries(schema)) {
const paramConfig = config as { required?: boolean; type?: string };
if (paramConfig.required && (params[key] === undefined || params[key] === null)) {
throw new ValidationError(`Parámetro requerido: ${key}`);
}
}
}
async findExecutionById(id: string, tenantId: string): Promise<ReportExecution> {
const execution = await queryOne<ReportExecution>(
`SELECT re.*,
rd.name as definition_name,
rd.code as definition_code,
u.full_name as requested_by_name
FROM reports.report_executions re
JOIN reports.report_definitions rd ON re.definition_id = rd.id
JOIN auth.users u ON re.requested_by = u.id
WHERE re.id = $1 AND re.tenant_id = $2`,
[id, tenantId]
);
if (!execution) {
throw new NotFoundError('Ejecución de reporte no encontrada');
}
return execution;
}
async findRecentExecutions(
tenantId: string,
definitionId?: string,
limit: number = 20
): Promise<ReportExecution[]> {
let sql = `
SELECT re.*,
rd.name as definition_name,
rd.code as definition_code,
u.full_name as requested_by_name
FROM reports.report_executions re
JOIN reports.report_definitions rd ON re.definition_id = rd.id
JOIN auth.users u ON re.requested_by = u.id
WHERE re.tenant_id = $1
`;
const params: any[] = [tenantId];
if (definitionId) {
sql += ` AND re.definition_id = $2`;
params.push(definitionId);
}
sql += ` ORDER BY re.created_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
return query<ReportExecution>(sql, params);
}
// ==================== SCHEDULES ====================
async findAllSchedules(tenantId: string): Promise<ReportSchedule[]> {
return query<ReportSchedule>(
`SELECT rs.*,
rd.name as definition_name
FROM reports.report_schedules rs
JOIN reports.report_definitions rd ON rs.definition_id = rd.id
WHERE rs.tenant_id = $1
ORDER BY rs.name`,
[tenantId]
);
}
async createSchedule(
data: {
definition_id: string;
name: string;
cron_expression: string;
default_parameters?: Record<string, any>;
company_id?: string;
timezone?: string;
delivery_method?: DeliveryMethod;
delivery_config?: Record<string, any>;
},
tenantId: string,
userId: string
): Promise<ReportSchedule> {
// Verificar que la definición existe
await this.findDefinitionById(data.definition_id, tenantId);
const schedule = await queryOne<ReportSchedule>(
`INSERT INTO reports.report_schedules (
tenant_id, definition_id, name, cron_expression,
default_parameters, company_id, timezone,
delivery_method, delivery_config, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
tenantId,
data.definition_id,
data.name,
data.cron_expression,
JSON.stringify(data.default_parameters || {}),
data.company_id || null,
data.timezone || 'America/Mexico_City',
data.delivery_method || 'none',
JSON.stringify(data.delivery_config || {}),
userId,
]
);
logger.info('Report schedule created', { scheduleId: schedule?.id, name: data.name });
return schedule!;
}
async toggleSchedule(id: string, tenantId: string, isActive: boolean): Promise<ReportSchedule> {
const schedule = await queryOne<ReportSchedule>(
`UPDATE reports.report_schedules
SET is_active = $3, updated_at = NOW()
WHERE id = $1 AND tenant_id = $2
RETURNING *`,
[id, tenantId, isActive]
);
if (!schedule) {
throw new NotFoundError('Programación no encontrada');
}
return schedule;
}
async deleteSchedule(id: string, tenantId: string): Promise<void> {
const result = await query(
`DELETE FROM reports.report_schedules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
// Check if any row was deleted
if (!result || result.length === 0) {
// Try to verify it existed
const exists = await queryOne<{ id: string }>(
`SELECT id FROM reports.report_schedules WHERE id = $1`,
[id]
);
if (!exists) {
throw new NotFoundError('Programación no encontrada');
}
}
}
// ==================== QUICK REPORTS ====================
async generateTrialBalance(
tenantId: string,
companyId: string | null,
dateFrom: string,
dateTo: string,
includeZero: boolean = false
): Promise<any[]> {
return query(
`SELECT * FROM reports.generate_trial_balance($1, $2, $3, $4, $5)`,
[tenantId, companyId, dateFrom, dateTo, includeZero]
);
}
async generateGeneralLedger(
tenantId: string,
companyId: string | null,
accountId: string,
dateFrom: string,
dateTo: string
): Promise<any[]> {
return query(
`SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`,
[tenantId, companyId, accountId, dateFrom, dateTo]
);
}
}
export const reportsService = new ReportsService();

View File

@ -1,8 +1,19 @@
// Re-export new services
export { ReportsService } from './reports.service';
export { ReportExecutionService } from './report-execution.service';
export { ReportSchedulerService } from './report-scheduler.service';
export { DashboardsService } from './dashboards.service';
// Re-export services
export {
reportsService,
ReportType,
ExecutionStatus,
DeliveryMethod,
ReportDefinition,
ReportExecution,
ReportSchedule,
CreateReportDefinitionDto,
ExecuteReportDto,
ReportFilters,
} from './reports.service.js';
export { ReportExecutionService } from './report-execution.service.js';
export { ReportSchedulerService } from './report-scheduler.service.js';
export { DashboardsService } from './dashboards.service.js';
// Legacy types and service (kept for backwards compatibility)
import { DataSource } from 'typeorm';

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
// Roles module exports
export { rolesService } from './roles.service.js';
export { permissionsService } from './permissions.service.js';
export { rolesService } from './services/roles.service.js';
export { permissionsService } from './services/permissions.service.js';
export { rolesController } from './roles.controller.js';
export { permissionsController } from './permissions.controller.js';
@ -9,5 +9,5 @@ export { default as rolesRoutes } from './roles.routes.js';
export { default as permissionsRoutes } from './permissions.routes.js';
// Types
export type { CreateRoleDto, UpdateRoleDto, RoleWithPermissions } from './roles.service.js';
export type { PermissionFilter, EffectivePermission } from './permissions.service.js';
export type { CreateRoleDto, UpdateRoleDto, RoleWithPermissions } from './services/roles.service.js';
export type { PermissionFilter, EffectivePermission } from './services/permissions.service.js';

View File

@ -1,6 +1,6 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { permissionsService } from './permissions.service.js';
import { permissionsService } from './services/permissions.service.js';
import { PermissionAction } from '../auth/entities/index.js';
import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js';

View File

@ -1,6 +1,6 @@
import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { rolesService } from './roles.service.js';
import { rolesService } from './services/roles.service.js';
import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js';
// Validation schemas

Some files were not shown because too many files have changed in this diff Show More