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:
parent
6f0548bc5b
commit
7a957a69c7
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 =====
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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 ==========
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 =====
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
4
src/modules/hr/services/index.ts
Normal file
4
src/modules/hr/services/index.ts
Normal 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';
|
||||
@ -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';
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -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 =====
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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 =====
|
||||
|
||||
@ -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';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
@ -1 +1,9 @@
|
||||
export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service';
|
||||
export {
|
||||
productsService,
|
||||
ProductSearchParams,
|
||||
CategorySearchParams,
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
CreateCategoryDto,
|
||||
UpdateCategoryDto,
|
||||
} from './products.service.js';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
6
src/modules/projects/services/index.ts
Normal file
6
src/modules/projects/services/index.ts
Normal 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';
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
@ -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
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user