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 after mocking
|
||||||
import { countriesService } from '../../modules/core/countries.service';
|
import { countriesService } from '../../modules/core/services/countries.service';
|
||||||
|
|
||||||
describe('CountriesService', () => {
|
describe('CountriesService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ jest.mock('../../config/typeorm', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Import after mocking
|
// Import after mocking
|
||||||
import { currenciesService } from '../../modules/core/currencies.service';
|
import { currenciesService } from '../../modules/core/services/currencies.service';
|
||||||
|
|
||||||
describe('CurrenciesService', () => {
|
describe('CurrenciesService', () => {
|
||||||
beforeEach(() => {
|
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', () => {
|
describe('StatesService', () => {
|
||||||
beforeEach(() => {
|
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', () => {
|
describe('UomService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// Validation schemas
|
// Validation schemas
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
export * from './auth.service.js';
|
export * from './services/auth.service.js';
|
||||||
export * from './auth.controller.js';
|
export * from './auth.controller.js';
|
||||||
export { default as authRoutes } from './auth.routes.js';
|
export { default as authRoutes } from './auth.routes.js';
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
export * from './apiKeys.service.js';
|
export * from './services/apiKeys.service.js';
|
||||||
export * from './apiKeys.controller.js';
|
export * from './apiKeys.controller.js';
|
||||||
export { default as apiKeysRoutes } from './apiKeys.routes.js';
|
export { default as apiKeysRoutes } from './apiKeys.routes.js';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
|
import { ValidationError, NotFoundError, UnauthorizedError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { User, UserStatus, Role } from './entities/index.js';
|
import { User, UserStatus, Role } from '../entities/index.js';
|
||||||
import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js';
|
import { tokenService, TokenPair, RequestMetadata } from './token.service.js';
|
||||||
import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js';
|
import { UnauthorizedError, ValidationError, NotFoundError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface LoginDto {
|
export interface LoginDto {
|
||||||
email: string;
|
email: string;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// Validation schemas (accept both snake_case and camelCase from frontend)
|
// 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 * from './companies.controller.js';
|
||||||
export { default as companiesRoutes } from './companies.routes.js';
|
export { default as companiesRoutes } from './companies.routes.js';
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Company } from '../auth/entities/index.js';
|
import { Company } from '../../auth/entities/index.js';
|
||||||
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
|
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ===== Interfaces =====
|
// ===== Interfaces =====
|
||||||
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
|
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './services/currencies.service.js';
|
||||||
import { countriesService } from './countries.service.js';
|
import { countriesService } from './services/countries.service.js';
|
||||||
import { statesService, CreateStateDto, UpdateStateDto } from './states.service.js';
|
import { statesService, CreateStateDto, UpdateStateDto } from './services/states.service.js';
|
||||||
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './currency-rates.service.js';
|
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './services/currency-rates.service.js';
|
||||||
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
|
import { uomService, CreateUomDto, UpdateUomDto } from './services/uom.service.js';
|
||||||
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
|
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './services/product-categories.service.js';
|
||||||
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './payment-terms.service.js';
|
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './services/payment-terms.service.js';
|
||||||
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './discount-rules.service.js';
|
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './services/discount-rules.service.js';
|
||||||
import { PaymentTermLineType } from './entities/payment-term.entity.js';
|
import { PaymentTermLineType } from './entities/payment-term.entity.js';
|
||||||
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
|
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
export * from './currencies.service.js';
|
export * from './services/currencies.service.js';
|
||||||
export * from './countries.service.js';
|
export * from './services/countries.service.js';
|
||||||
export * from './uom.service.js';
|
export * from './services/uom.service.js';
|
||||||
export * from './product-categories.service.js';
|
export * from './services/product-categories.service.js';
|
||||||
export * from './sequences.service.js';
|
export * from './services/sequences.service.js';
|
||||||
export * from './payment-terms.service.js';
|
export * from './services/payment-terms.service.js';
|
||||||
export * from './discount-rules.service.js';
|
export * from './services/discount-rules.service.js';
|
||||||
export * from './entities/index.js';
|
export * from './entities/index.js';
|
||||||
export * from './core.controller.js';
|
export * from './core.controller.js';
|
||||||
export { default as coreRoutes } from './core.routes.js';
|
export { default as coreRoutes } from './core.routes.js';
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Country } from './entities/country.entity.js';
|
import { Country } from '../entities/country.entity.js';
|
||||||
import { NotFoundError } from '../../shared/errors/index.js';
|
import { NotFoundError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
class CountriesService {
|
class CountriesService {
|
||||||
private repository: Repository<Country>;
|
private repository: Repository<Country>;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Currency } from './entities/currency.entity.js';
|
import { Currency } from '../entities/currency.entity.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface CreateCurrencyDto {
|
export interface CreateCurrencyDto {
|
||||||
code: string;
|
code: string;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { Repository, LessThanOrEqual } from 'typeorm';
|
import { Repository, LessThanOrEqual } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { CurrencyRate, RateSource } from './entities/currency-rate.entity.js';
|
import { CurrencyRate, RateSource } from '../entities/currency-rate.entity.js';
|
||||||
import { Currency } from './entities/currency.entity.js';
|
import { Currency } from '../entities/currency.entity.js';
|
||||||
import { NotFoundError } from '../../shared/errors/index.js';
|
import { NotFoundError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface CreateCurrencyRateDto {
|
export interface CreateCurrencyRateDto {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
|
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import {
|
import {
|
||||||
DiscountRule,
|
DiscountRule,
|
||||||
DiscountType,
|
DiscountType,
|
||||||
DiscountAppliesTo,
|
DiscountAppliesTo,
|
||||||
DiscountCondition,
|
DiscountCondition,
|
||||||
} from './entities/discount-rule.entity.js';
|
} from '../entities/discount-rule.entity.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import {
|
import {
|
||||||
PaymentTerm,
|
PaymentTerm,
|
||||||
PaymentTermLine,
|
PaymentTermLine,
|
||||||
PaymentTermLineType,
|
PaymentTermLineType,
|
||||||
} from './entities/payment-term.entity.js';
|
} from '../entities/payment-term.entity.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { ProductCategory } from './entities/product-category.entity.js';
|
import { ProductCategory } from '../entities/product-category.entity.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface CreateProductCategoryDto {
|
export interface CreateProductCategoryDto {
|
||||||
name: string;
|
name: string;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository, DataSource } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Sequence, ResetPeriod } from './entities/sequence.entity.js';
|
import { Sequence, ResetPeriod } from '../entities/sequence.entity.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { State } from './entities/state.entity.js';
|
import { State } from '../entities/state.entity.js';
|
||||||
import { NotFoundError } from '../../shared/errors/index.js';
|
import { NotFoundError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface CreateStateDto {
|
export interface CreateStateDto {
|
||||||
countryId: string;
|
countryId: string;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Uom, UomType } from './entities/uom.entity.js';
|
import { Uom, UomType } from '../entities/uom.entity.js';
|
||||||
import { UomCategory } from './entities/uom-category.entity.js';
|
import { UomCategory } from '../entities/uom-category.entity.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface CreateUomDto {
|
export interface CreateUomDto {
|
||||||
name: string;
|
name: string;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './leads.service.js';
|
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './services/leads.service.js';
|
||||||
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './opportunities.service.js';
|
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './services/opportunities.service.js';
|
||||||
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './stages.service.js';
|
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './services/stages.service.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export * from './leads.service.js';
|
export * from './services/leads.service.js';
|
||||||
export * from './opportunities.service.js';
|
export * from './services/opportunities.service.js';
|
||||||
export * from './stages.service.js';
|
export * from './services/stages.service.js';
|
||||||
export * from './activities.service.js';
|
export * from './services/activities.service.js';
|
||||||
export * from './forecasting.service.js';
|
export * from './services/forecasting.service.js';
|
||||||
export * from './crm.controller.js';
|
export * from './crm.controller.js';
|
||||||
export { default as crmRoutes } from './crm.routes.js';
|
export { default as crmRoutes } from './crm.routes.js';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
|
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
|
||||||
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
|
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 { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { LeadSource } from './leads.service.js';
|
import { LeadSource } from './leads.service.js';
|
||||||
|
|
||||||
export type OpportunityStatus = 'open' | 'won' | 'lost';
|
export type OpportunityStatus = 'open' | 'won' | 'lost';
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
// ========== LEAD STAGES ==========
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
@ -108,7 +108,7 @@ describe('AccountsService', () => {
|
|||||||
describe('AccountTypes Operations', () => {
|
describe('AccountTypes Operations', () => {
|
||||||
it('should return all account types', async () => {
|
it('should return all account types', async () => {
|
||||||
// Import dynamically to get fresh instance with mocks
|
// 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();
|
const result = await accountsService.findAllAccountTypes();
|
||||||
|
|
||||||
@ -119,7 +119,7 @@ describe('AccountsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return account type by ID', async () => {
|
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);
|
const result = await accountsService.findAccountTypeById(mockAccountTypeId);
|
||||||
|
|
||||||
@ -132,7 +132,7 @@ describe('AccountsService', () => {
|
|||||||
it('should throw NotFoundError when account type not found', async () => {
|
it('should throw NotFoundError when account type not found', async () => {
|
||||||
mockAccountTypeRepository.findOne = jest.fn().mockResolvedValue(null);
|
mockAccountTypeRepository.findOne = jest.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
const { accountsService } = await import('../accounts.service.js');
|
const { accountsService } = await import('../services/accounts.service.js');
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
accountsService.findAccountTypeById('non-existent-id')
|
accountsService.findAccountTypeById('non-existent-id')
|
||||||
@ -142,7 +142,7 @@ describe('AccountsService', () => {
|
|||||||
|
|
||||||
describe('Account CRUD Operations', () => {
|
describe('Account CRUD Operations', () => {
|
||||||
it('should find all accounts with filters', async () => {
|
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, {
|
const result = await accountsService.findAll(mockTenantId, {
|
||||||
companyId: mockCompanyId,
|
companyId: mockCompanyId,
|
||||||
@ -164,7 +164,7 @@ describe('AccountsService', () => {
|
|||||||
isReconcilable: true,
|
isReconcilable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { accountsService } = await import('../accounts.service.js');
|
const { accountsService } = await import('../services/accounts.service.js');
|
||||||
|
|
||||||
// Service signature: create(dto, tenantId, userId)
|
// Service signature: create(dto, tenantId, userId)
|
||||||
const result = await accountsService.create(createDto, mockTenantId, 'mock-user-id');
|
const result = await accountsService.create(createDto, mockTenantId, 'mock-user-id');
|
||||||
@ -175,7 +175,7 @@ describe('AccountsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should find account by ID', async () => {
|
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)
|
// Service signature: findById(id, tenantId)
|
||||||
const result = await accountsService.findById(
|
const result = await accountsService.findById(
|
||||||
@ -190,7 +190,7 @@ describe('AccountsService', () => {
|
|||||||
it('should throw NotFoundError when account not found', async () => {
|
it('should throw NotFoundError when account not found', async () => {
|
||||||
mockQueryBuilder.getOne = jest.fn().mockResolvedValue(null);
|
mockQueryBuilder.getOne = jest.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
const { accountsService } = await import('../accounts.service.js');
|
const { accountsService } = await import('../services/accounts.service.js');
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
accountsService.findById('non-existent-id', mockTenantId)
|
accountsService.findById('non-existent-id', mockTenantId)
|
||||||
@ -202,7 +202,7 @@ describe('AccountsService', () => {
|
|||||||
name: 'Updated Bank Account',
|
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)
|
// Service signature: update(id, dto, tenantId, userId)
|
||||||
const result = await accountsService.update(
|
const result = await accountsService.update(
|
||||||
@ -217,7 +217,7 @@ describe('AccountsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should soft delete an account', async () => {
|
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)
|
// Service signature: delete(id, tenantId, userId)
|
||||||
await accountsService.delete(mockAccount.id as string, mockTenantId, 'mock-user-id');
|
await accountsService.delete(mockAccount.id as string, mockTenantId, 'mock-user-id');
|
||||||
@ -241,7 +241,7 @@ describe('AccountsService', () => {
|
|||||||
accountTypeId: mockAccountTypeId,
|
accountTypeId: mockAccountTypeId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { accountsService } = await import('../accounts.service.js');
|
const { accountsService } = await import('../services/accounts.service.js');
|
||||||
|
|
||||||
// This should handle duplicate validation
|
// This should handle duplicate validation
|
||||||
// Exact behavior depends on service implementation
|
// Exact behavior depends on service implementation
|
||||||
@ -258,7 +258,7 @@ describe('AccountsService', () => {
|
|||||||
//
|
//
|
||||||
// mockAccountRepository.find = jest.fn().mockResolvedValue([mockAccount]);
|
// 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(
|
// const result = await accountsService.getChartOfAccounts(
|
||||||
// mockTenantId,
|
// mockTenantId,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { PaymentsService } from '../payments.service';
|
import { PaymentsService } from '../services/payments.service';
|
||||||
import { Payment, PaymentMethod, PaymentStatus } from '../entities';
|
import { Payment, PaymentMethod, PaymentStatus } from '../entities';
|
||||||
import { CreatePaymentDto, UpdatePaymentDto } from '../dto';
|
import { CreatePaymentDto, UpdatePaymentDto } from '../dto';
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './accounts.service.js';
|
import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './services/accounts.service.js';
|
||||||
import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './journals.service.js';
|
import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './services/journals.service.js';
|
||||||
import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './journal-entries.service.js';
|
import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './services/journal-entries.service.js';
|
||||||
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './invoices.service.js';
|
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './services/invoices.service.js';
|
||||||
import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './payments.service.js';
|
import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './services/payments.service.js';
|
||||||
import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.service.js';
|
import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './services/taxes.service.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
export * from './accounts.service.js';
|
export * from './services/accounts.service.js';
|
||||||
export * from './journals.service.js';
|
export * from './services/journals.service.js';
|
||||||
export * from './journal-entries.service.js';
|
export * from './services/journal-entries.service.js';
|
||||||
export * from './invoices.service.js';
|
export * from './services/invoices.service.js';
|
||||||
export * from './payments.service.js';
|
export * from './services/payments.service.js';
|
||||||
export * from './taxes.service.js';
|
export * from './services/taxes.service.js';
|
||||||
export * from './gl-posting.service.js';
|
export * from './services/gl-posting.service.js';
|
||||||
export * from './financial.controller.js';
|
export * from './financial.controller.js';
|
||||||
export { default as financialRoutes } from './financial.routes.js';
|
export { default as financialRoutes } from './financial.routes.js';
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Account, AccountType } from './entities/index.js';
|
import { Account, AccountType } from '../entities/index.js';
|
||||||
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
|
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ===== Interfaces =====
|
// ===== Interfaces =====
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
import { query, queryOne, getClient, PoolClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { AccountMappingType } from './entities/account-mapping.entity.js';
|
import { AccountMappingType } from '../entities/account-mapping.entity.js';
|
||||||
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
import { sequencesService, SEQUENCE_CODES } from '../../core/services/sequences.service.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { taxesService } from './taxes.service.js';
|
import { taxesService } from './taxes.service.js';
|
||||||
import { glPostingService, InvoiceForPosting } from './gl-posting.service.js';
|
import { glPostingService, InvoiceForPosting } from './gl-posting.service.js';
|
||||||
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
import { sequencesService, SEQUENCE_CODES } from '../../core/services/sequences.service.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface InvoiceLine {
|
export interface InvoiceLine {
|
||||||
id: string;
|
id: string;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type EntryStatus = 'draft' | 'posted' | 'cancelled';
|
export type EntryStatus = 'draft' | 'posted' | 'cancelled';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface PaymentInvoice {
|
export interface PaymentInvoice {
|
||||||
invoice_id: string;
|
invoice_id: string;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface Tax {
|
export interface Tax {
|
||||||
id: string;
|
id: string;
|
||||||
@ -6,7 +6,7 @@ import {
|
|||||||
paymentMethodsService,
|
paymentMethodsService,
|
||||||
paymentTypesService,
|
paymentTypesService,
|
||||||
withholdingTypesService,
|
withholdingTypesService,
|
||||||
} from './fiscal-catalogs.service.js';
|
} from './services/fiscal-catalogs.service.js';
|
||||||
import { PersonType } from './entities/fiscal-regime.entity.js';
|
import { PersonType } from './entities/fiscal-regime.entity.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export * from './entities/index.js';
|
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 { fiscalController } from './fiscal.controller.js';
|
||||||
export { default as fiscalRoutes } from './fiscal.routes.js';
|
export { default as fiscalRoutes } from './fiscal.routes.js';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import {
|
import {
|
||||||
TaxCategory,
|
TaxCategory,
|
||||||
FiscalRegime,
|
FiscalRegime,
|
||||||
@ -8,9 +8,9 @@ import {
|
|||||||
PaymentType,
|
PaymentType,
|
||||||
WithholdingType,
|
WithholdingType,
|
||||||
PersonType,
|
PersonType,
|
||||||
} from './entities/index.js';
|
} from '../entities/index.js';
|
||||||
import { NotFoundError } from '../../shared/errors/index.js';
|
import { NotFoundError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// TAX CATEGORIES SERVICE
|
// TAX CATEGORIES SERVICE
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { HealthService } from './health.service';
|
import { HealthService } from './services/health.service';
|
||||||
|
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
public router: Router;
|
public router: Router;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { HealthService } from './health.service';
|
import { HealthService } from './services/health.service';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
export interface HealthModuleOptions {
|
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';
|
export { HealthController } from './health.controller';
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export { HealthModule, HealthModuleOptions } from './health.module';
|
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';
|
export { HealthController } from './health.controller';
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './employees.service.js';
|
import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './services/employees.service.js';
|
||||||
import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './departments.service.js';
|
import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './services/departments.service.js';
|
||||||
import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './contracts.service.js';
|
import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './services/contracts.service.js';
|
||||||
import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './leaves.service.js';
|
import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './services/leaves.service.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
export * from './employees.service.js';
|
export * from './services/index.js';
|
||||||
export * from './departments.service.js';
|
|
||||||
export * from './contracts.service.js';
|
|
||||||
export * from './leaves.service.js';
|
|
||||||
export * from './hr.controller.js';
|
export * from './hr.controller.js';
|
||||||
export { default as hrRoutes } from './hr.routes.js';
|
export { default as hrRoutes } from './hr.routes.js';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled';
|
export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled';
|
||||||
export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time';
|
export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time';
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface Department {
|
export interface Department {
|
||||||
id: string;
|
id: string;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated';
|
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 { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled';
|
export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled';
|
||||||
export type LeaveType = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other';
|
export type LeaveType = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other';
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './products.service.js';
|
import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './services/products.service.js';
|
||||||
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './warehouses.service.js';
|
import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './services/warehouses.service.js';
|
||||||
import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './locations.service.js';
|
import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './services/locations.service.js';
|
||||||
import { pickingsService, CreatePickingDto, PickingFilters } from './pickings.service.js';
|
import { pickingsService, CreatePickingDto, PickingFilters } from './services/pickings.service.js';
|
||||||
import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './lots.service.js';
|
import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './services/lots.service.js';
|
||||||
import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './adjustments.service.js';
|
import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './services/adjustments.service.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
import { valuationService } from './valuation.service.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';
|
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';
|
||||||
|
|
||||||
@ -2,7 +2,25 @@ export {
|
|||||||
InventoryService,
|
InventoryService,
|
||||||
StockSearchParams,
|
StockSearchParams,
|
||||||
MovementSearchParams,
|
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
|
// Stock reservation service for sales orders and transfers
|
||||||
export {
|
export {
|
||||||
@ -11,7 +29,7 @@ export {
|
|||||||
ReservationResult,
|
ReservationResult,
|
||||||
ReservationLineResult,
|
ReservationLineResult,
|
||||||
StockAvailability,
|
StockAvailability,
|
||||||
} from '../stock-reservation.service.js';
|
} from './stock-reservation.service.js';
|
||||||
|
|
||||||
// Valuation service for FIFO/Average costing
|
// Valuation service for FIFO/Average costing
|
||||||
export {
|
export {
|
||||||
@ -22,7 +40,7 @@ export {
|
|||||||
ValuationSummary,
|
ValuationSummary,
|
||||||
FifoConsumptionResult,
|
FifoConsumptionResult,
|
||||||
ProductCostResult,
|
ProductCostResult,
|
||||||
} from '../valuation.service.js';
|
} from './valuation.service.js';
|
||||||
|
|
||||||
// Reorder alerts service for stock level monitoring
|
// Reorder alerts service for stock level monitoring
|
||||||
export {
|
export {
|
||||||
@ -32,4 +50,4 @@ export {
|
|||||||
StockSummary,
|
StockSummary,
|
||||||
ReorderAlertFilters,
|
ReorderAlertFilters,
|
||||||
StockLevelFilters,
|
StockLevelFilters,
|
||||||
} from '../reorder-alerts.service.js';
|
} from './reorder-alerts.service.js';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type LocationType = 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
|
export type LocationType = 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface Lot {
|
export interface Lot {
|
||||||
id: string;
|
id: string;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { stockReservationService, ReservationLine } from './stock-reservation.service.js';
|
import { stockReservationService, ReservationLine } from './stock-reservation.service.js';
|
||||||
import { valuationService } from './valuation.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 PickingType = 'incoming' | 'outgoing' | 'internal';
|
||||||
export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled';
|
export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled';
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { Repository, IsNull, ILike } from 'typeorm';
|
import { Repository, IsNull, ILike } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Product, ProductType, TrackingType, ValuationMethod } from './entities/product.entity.js';
|
import { Product, ProductType, TrackingType, ValuationMethod } from '../entities/product.entity.js';
|
||||||
import { StockQuant } from './entities/stock-quant.entity.js';
|
import { StockQuant } from '../entities/stock-quant.entity.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ===== Interfaces =====
|
// ===== Interfaces =====
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
import { query, queryOne, getClient, PoolClient } from '../../../config/database.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stock Reservation Service
|
* Stock Reservation Service
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
import { query, queryOne, getClient, PoolClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Warehouse } from '../warehouses/entities/warehouse.entity.js';
|
import { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
|
||||||
import { Location } from './entities/location.entity.js';
|
import { Location } from '../entities/location.entity.js';
|
||||||
import { StockQuant } from './entities/stock-quant.entity.js';
|
import { StockQuant } from '../entities/stock-quant.entity.js';
|
||||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
|
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ===== Interfaces =====
|
// ===== Interfaces =====
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { getRepositoryToken } from '@nestjs/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 { Partner, PartnerStatus, PartnerType } from '../entities';
|
||||||
import { CreatePartnerDto, UpdatePartnerDto } from '../dto';
|
import { CreatePartnerDto, UpdatePartnerDto } from '../dto';
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ jest.mock('../../../shared/utils/logger.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Import after mocking
|
// Import after mocking
|
||||||
import { partnersService } from '../partners.service.js';
|
import { partnersService } from '../services/partners.service.js';
|
||||||
import { NotFoundError, ValidationError } from '../../../shared/types/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/types/index.js';
|
||||||
|
|
||||||
describe('PartnersService', () => {
|
describe('PartnersService', () => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// Validation schemas (accept both snake_case and camelCase from frontend)
|
// 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 { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
||||||
import { rankingService, ABCClassification } from './ranking.service.js';
|
import { rankingService, ABCClassification } from './services/index.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// VALIDATION SCHEMAS
|
// 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 { Repository, IsNull, Like } from 'typeorm';
|
||||||
import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from '../entities';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import {
|
import { Partner, PartnerType } from '../entities/index.js';
|
||||||
CreatePartnerDto,
|
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
|
||||||
UpdatePartnerDto,
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
CreatePartnerAddressDto,
|
|
||||||
CreatePartnerContactDto,
|
|
||||||
CreatePartnerBankAccountDto,
|
|
||||||
} from '../dto';
|
|
||||||
|
|
||||||
export interface PartnerSearchParams {
|
// Re-export PartnerType for controller use
|
||||||
tenantId: string;
|
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;
|
search?: string;
|
||||||
partnerType?: 'customer' | 'supplier' | 'both';
|
partnerType?: PartnerType;
|
||||||
category?: string;
|
category?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
salesRepId?: string;
|
isVerified?: boolean;
|
||||||
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PartnersService {
|
export interface PartnerWithRelations extends Partner {
|
||||||
constructor(
|
// Add computed fields if needed
|
||||||
private readonly partnerRepository: Repository<Partner>,
|
}
|
||||||
private readonly addressRepository: Repository<PartnerAddress>,
|
|
||||||
private readonly contactRepository: Repository<PartnerContact>,
|
|
||||||
private readonly bankAccountRepository: Repository<PartnerBankAccount>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ==================== Partners ====================
|
// ===== PartnersService Class =====
|
||||||
|
|
||||||
async findAll(params: PartnerSearchParams): Promise<{ data: Partner[]; total: number }> {
|
class PartnersService {
|
||||||
const {
|
private partnerRepository: Repository<Partner>;
|
||||||
tenantId,
|
|
||||||
search,
|
|
||||||
partnerType,
|
|
||||||
category,
|
|
||||||
isActive,
|
|
||||||
salesRepId,
|
|
||||||
limit = 50,
|
|
||||||
offset = 0,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const where: FindOptionsWhere<Partner>[] = [];
|
constructor() {
|
||||||
const baseWhere: FindOptionsWhere<Partner> = { tenantId };
|
this.partnerRepository = AppDataSource.getRepository(Partner);
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
const queryBuilder = this.partnerRepository
|
||||||
return this.partnerRepository.findOne({ where: { code, tenantId } });
|
.createQueryBuilder('partner')
|
||||||
}
|
.where('partner.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('partner.deletedAt IS NULL');
|
||||||
|
|
||||||
async findByTaxId(taxId: string, tenantId: string): Promise<Partner | null> {
|
// Apply search filter
|
||||||
return this.partnerRepository.findOne({ where: { taxId, tenantId } });
|
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)',
|
||||||
async create(tenantId: string, dto: CreatePartnerDto, createdBy?: string): Promise<Partner> {
|
{ search: `%${search}%` }
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
tenantId: string,
|
|
||||||
dto: UpdatePartnerDto,
|
dto: UpdatePartnerDto,
|
||||||
updatedBy?: string
|
tenantId: string,
|
||||||
): Promise<Partner | null> {
|
userId: string
|
||||||
const partner = await this.findOne(id, tenantId);
|
): Promise<Partner> {
|
||||||
if (!partner) return null;
|
try {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
// If changing code, check for duplicates
|
// Update allowed fields
|
||||||
if (dto.code && dto.code !== partner.code) {
|
if (dto.displayName !== undefined) existing.displayName = dto.displayName;
|
||||||
const existing = await this.findByCode(dto.code, tenantId);
|
if (dto.legalName !== undefined) existing.legalName = dto.legalName as string;
|
||||||
if (existing) {
|
if (dto.partnerType !== undefined) existing.partnerType = dto.partnerType;
|
||||||
throw new Error('A partner with this code already exists');
|
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) {
|
* Soft delete a partner
|
||||||
const existing = await this.findByTaxId(dto.taxId, tenantId);
|
*/
|
||||||
if (existing && existing.id !== id) {
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
throw new Error('A partner with this tax ID already exists');
|
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);
|
* Get customers only
|
||||||
if (!partner) return false;
|
*/
|
||||||
|
async findCustomers(
|
||||||
const result = await this.partnerRepository.softDelete(id);
|
tenantId: string,
|
||||||
return (result.affected ?? 0) > 0;
|
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({
|
* Get suppliers only
|
||||||
where: [
|
*/
|
||||||
{ tenantId, partnerType: 'customer', isActive: true },
|
async findSuppliers(
|
||||||
{ tenantId, partnerType: 'both', isActive: true },
|
tenantId: string,
|
||||||
],
|
filters: Omit<PartnerFilters, 'partnerType'>
|
||||||
order: { displayName: 'ASC' },
|
): Promise<{ data: Partner[]; total: number }> {
|
||||||
});
|
return this.findAll(tenantId, { ...filters, partnerType: 'supplier' });
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Export Singleton Instance =====
|
||||||
|
|
||||||
|
export const partnersService = new PartnersService();
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { AppDataSource } from '../../config/typeorm.js';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import { Partner } from './entities/index.js';
|
import { Partner } from '../entities/index.js';
|
||||||
import { NotFoundError } from '../../shared/types/index.js';
|
import { NotFoundError } from '../../../shared/types/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -16,7 +16,7 @@ jest.mock('../../../config/typeorm.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Import after mocking
|
// Import after mocking
|
||||||
import { productsService } from '../products.service.js';
|
import { productsService } from '../services/products.service.js';
|
||||||
|
|
||||||
describe('ProductsService', () => {
|
describe('ProductsService', () => {
|
||||||
const tenantId = 'test-tenant-uuid';
|
const tenantId = 'test-tenant-uuid';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// Validation schemas
|
// 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 { FindOptionsWhere, ILike, IsNull } from 'typeorm';
|
||||||
import { Product, ProductCategory } from '../entities';
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
import {
|
import { Product } from '../entities/product.entity.js';
|
||||||
CreateProductDto,
|
import { ProductCategory } from '../entities/product-category.entity.js';
|
||||||
UpdateProductDto,
|
|
||||||
CreateProductCategoryDto,
|
|
||||||
UpdateProductCategoryDto,
|
|
||||||
} from '../dto';
|
|
||||||
|
|
||||||
export interface ProductSearchParams {
|
export interface ProductSearchParams {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@ -28,11 +24,64 @@ export interface CategorySearchParams {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProductsService {
|
export interface CreateProductDto {
|
||||||
constructor(
|
sku: string;
|
||||||
private readonly productRepository: Repository<Product>,
|
name: string;
|
||||||
private readonly categoryRepository: Repository<ProductCategory>
|
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 ====================
|
// ==================== Products ====================
|
||||||
|
|
||||||
@ -50,7 +99,7 @@ export class ProductsService {
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const where: FindOptionsWhere<Product>[] = [];
|
const where: FindOptionsWhere<Product>[] = [];
|
||||||
const baseWhere: FindOptionsWhere<Product> = { tenantId };
|
const baseWhere: FindOptionsWhere<Product> = { tenantId, deletedAt: IsNull() };
|
||||||
|
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
baseWhere.categoryId = categoryId;
|
baseWhere.categoryId = categoryId;
|
||||||
@ -76,8 +125,7 @@ export class ProductsService {
|
|||||||
where.push(
|
where.push(
|
||||||
{ ...baseWhere, name: ILike(`%${search}%`) },
|
{ ...baseWhere, name: ILike(`%${search}%`) },
|
||||||
{ ...baseWhere, sku: ILike(`%${search}%`) },
|
{ ...baseWhere, sku: ILike(`%${search}%`) },
|
||||||
{ ...baseWhere, barcode: ILike(`%${search}%`) },
|
{ ...baseWhere, barcode: ILike(`%${search}%`) }
|
||||||
{ ...baseWhere, description: ILike(`%${search}%`) }
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
where.push(baseWhere);
|
where.push(baseWhere);
|
||||||
@ -96,37 +144,41 @@ export class ProductsService {
|
|||||||
|
|
||||||
async findOne(id: string, tenantId: string): Promise<Product | null> {
|
async findOne(id: string, tenantId: string): Promise<Product | null> {
|
||||||
return this.productRepository.findOne({
|
return this.productRepository.findOne({
|
||||||
where: { id, tenantId },
|
where: { id, tenantId, deletedAt: IsNull() },
|
||||||
relations: ['category'],
|
relations: ['category'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBySku(sku: string, tenantId: string): Promise<Product | null> {
|
async findBySku(sku: string, tenantId: string): Promise<Product | null> {
|
||||||
return this.productRepository.findOne({
|
return this.productRepository.findOne({
|
||||||
where: { sku, tenantId },
|
where: { sku, tenantId, deletedAt: IsNull() },
|
||||||
relations: ['category'],
|
relations: ['category'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByBarcode(barcode: string, tenantId: string): Promise<Product | null> {
|
async findByBarcode(barcode: string, tenantId: string): Promise<Product | null> {
|
||||||
return this.productRepository.findOne({
|
return this.productRepository.findOne({
|
||||||
where: { barcode, tenantId },
|
where: { barcode, tenantId, deletedAt: IsNull() },
|
||||||
relations: ['category'],
|
relations: ['category'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise<Product> {
|
async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise<Product> {
|
||||||
// Check for existing SKU
|
// Validate unique SKU within tenant (RLS compliance)
|
||||||
const existingSku = await this.findBySku(dto.sku, tenantId);
|
const existingSku = await this.productRepository.findOne({
|
||||||
|
where: { sku: dto.sku, tenantId, deletedAt: IsNull() },
|
||||||
|
});
|
||||||
if (existingSku) {
|
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) {
|
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) {
|
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,
|
tenantId,
|
||||||
createdBy,
|
createdBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.productRepository.save(product);
|
return this.productRepository.save(product);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(id: string, tenantId: string, dto: UpdateProductDto, updatedBy?: string): Promise<Product | null> {
|
||||||
id: string,
|
|
||||||
tenantId: string,
|
|
||||||
dto: UpdateProductDto,
|
|
||||||
updatedBy?: string
|
|
||||||
): Promise<Product | null> {
|
|
||||||
const product = await this.findOne(id, tenantId);
|
const product = await this.findOne(id, tenantId);
|
||||||
if (!product) return null;
|
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) {
|
if (dto.sku && dto.sku !== product.sku) {
|
||||||
const existing = await this.findBySku(dto.sku, tenantId);
|
const existingSku = await this.productRepository.findOne({
|
||||||
if (existing) {
|
where: { sku: dto.sku, tenantId, deletedAt: IsNull() },
|
||||||
throw new Error('A product with this SKU already exists');
|
});
|
||||||
|
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) {
|
if (dto.barcode && dto.barcode !== product.barcode) {
|
||||||
const existing = await this.findByBarcode(dto.barcode, tenantId);
|
const existingBarcode = await this.productRepository.findOne({
|
||||||
if (existing && existing.id !== id) {
|
where: { barcode: dto.barcode, tenantId, deletedAt: IsNull() },
|
||||||
throw new Error('A product with this barcode already exists');
|
});
|
||||||
|
if (existingBarcode) {
|
||||||
|
throw new Error(`Product with barcode '${dto.barcode}' already exists`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(product, {
|
Object.assign(product, { ...dto, updatedBy });
|
||||||
...dto,
|
|
||||||
updatedBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.productRepository.save(product);
|
return this.productRepository.save(product);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string, tenantId: string): Promise<boolean> {
|
async delete(id: string, tenantId: string): Promise<boolean> {
|
||||||
const product = await this.findOne(id, tenantId);
|
const result = await this.productRepository.softDelete({ id, tenantId });
|
||||||
if (!product) return false;
|
|
||||||
|
|
||||||
const result = await this.productRepository.softDelete(id);
|
|
||||||
return (result.affected ?? 0) > 0;
|
return (result.affected ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSellableProducts(tenantId: string): Promise<Product[]> {
|
async getSellableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> {
|
||||||
return this.productRepository.find({
|
return this.findAll({ tenantId, isSellable: true, isActive: true, limit, offset });
|
||||||
where: { tenantId, isActive: true, isSellable: true },
|
|
||||||
relations: ['category'],
|
|
||||||
order: { name: 'ASC' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPurchasableProducts(tenantId: string): Promise<Product[]> {
|
async getPurchasableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> {
|
||||||
return this.productRepository.find({
|
return this.findAll({ tenantId, isPurchasable: true, isActive: true, limit, offset });
|
||||||
where: { tenantId, isActive: true, isPurchasable: true },
|
|
||||||
relations: ['category'],
|
|
||||||
order: { name: 'ASC' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Categories ====================
|
// ==================== Categories ====================
|
||||||
|
|
||||||
async findAllCategories(
|
async findAllCategories(params: CategorySearchParams): Promise<{ data: ProductCategory[]; total: number }> {
|
||||||
params: CategorySearchParams
|
const { tenantId, search, parentId, isActive, limit = 50, offset = 0 } = params;
|
||||||
): Promise<{ data: ProductCategory[]; total: number }> {
|
|
||||||
const { tenantId, search, parentId, isActive, limit = 100, offset = 0 } = params;
|
|
||||||
|
|
||||||
const where: FindOptionsWhere<ProductCategory>[] = [];
|
const where: FindOptionsWhere<ProductCategory> = { tenantId, deletedAt: IsNull() };
|
||||||
const baseWhere: FindOptionsWhere<ProductCategory> = { tenantId };
|
|
||||||
|
|
||||||
if (parentId !== undefined) {
|
if (parentId) {
|
||||||
baseWhere.parentId = parentId || undefined;
|
where.parentId = parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive !== undefined) {
|
if (isActive !== undefined) {
|
||||||
baseWhere.isActive = isActive;
|
where.isActive = isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.push(
|
const [data, total] = await this.categoryRepository.findAndCount({
|
||||||
{ ...baseWhere, name: ILike(`%${search}%`) },
|
where: [
|
||||||
{ ...baseWhere, code: ILike(`%${search}%`) }
|
{ ...where, name: ILike(`%${search}%`) },
|
||||||
);
|
{ ...where, code: ILike(`%${search}%`) },
|
||||||
} else {
|
],
|
||||||
where.push(baseWhere);
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
return { data, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await this.categoryRepository.findAndCount({
|
const [data, total] = await this.categoryRepository.findAndCount({
|
||||||
@ -234,95 +270,31 @@ export class ProductsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findCategory(id: string, tenantId: string): Promise<ProductCategory | null> {
|
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> {
|
async createCategory(tenantId: string, dto: CreateCategoryDto, _createdBy?: string): Promise<ProductCategory> {
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = this.categoryRepository.create({
|
const category = this.categoryRepository.create({
|
||||||
...dto,
|
...dto,
|
||||||
tenantId,
|
tenantId,
|
||||||
hierarchyPath,
|
|
||||||
hierarchyLevel,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.categoryRepository.save(category);
|
return this.categoryRepository.save(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCategory(
|
async updateCategory(id: string, tenantId: string, dto: UpdateCategoryDto, _updatedBy?: string): Promise<ProductCategory | null> {
|
||||||
id: string,
|
|
||||||
tenantId: string,
|
|
||||||
dto: UpdateProductCategoryDto
|
|
||||||
): Promise<ProductCategory | null> {
|
|
||||||
const category = await this.findCategory(id, tenantId);
|
const category = await this.findCategory(id, tenantId);
|
||||||
if (!category) return null;
|
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);
|
Object.assign(category, dto);
|
||||||
return this.categoryRepository.save(category);
|
return this.categoryRepository.save(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCategory(id: string, tenantId: string): Promise<boolean> {
|
async deleteCategory(id: string, tenantId: string): Promise<boolean> {
|
||||||
const category = await this.findCategory(id, tenantId);
|
const result = await this.categoryRepository.softDelete({ 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);
|
|
||||||
return (result.affected ?? 0) > 0;
|
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 './services/projects.service.js';
|
||||||
export * from './tasks.service.js';
|
export * from './services/tasks.service.js';
|
||||||
export * from './timesheets.service.js';
|
export * from './services/timesheets.service.js';
|
||||||
export * from './billing.service.js';
|
export * from './services/billing.service.js';
|
||||||
export * from './hr-integration.service.js';
|
export * from './services/hr-integration.service.js';
|
||||||
export * from './projects.controller.js';
|
export * from './projects.controller.js';
|
||||||
export { default as projectsRoutes } from './projects.routes.js';
|
export { default as projectsRoutes } from './projects.routes.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { projectsService, CreateProjectDto, UpdateProjectDto, ProjectFilters } from './projects.service.js';
|
import { projectsService, CreateProjectDto, UpdateProjectDto, ProjectFilters } from './services/projects.service.js';
|
||||||
import { tasksService, CreateTaskDto, UpdateTaskDto, TaskFilters } from './tasks.service.js';
|
import { tasksService, CreateTaskDto, UpdateTaskDto, TaskFilters } from './services/tasks.service.js';
|
||||||
import { timesheetsService, CreateTimesheetDto, UpdateTimesheetDto, TimesheetFilters } from './timesheets.service.js';
|
import { timesheetsService, CreateTimesheetDto, UpdateTimesheetDto, TimesheetFilters } from './services/timesheets.service.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// 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 { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne } from '../../config/database.js';
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export interface Timesheet {
|
export interface Timesheet {
|
||||||
id: string;
|
id: string;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './purchases.service.js';
|
import { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './services/purchases.service.js';
|
||||||
import { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './rfqs.service.js';
|
import { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './services/rfqs.service.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,10 @@ import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto';
|
|||||||
// Export 3-Way Matching Service
|
// Export 3-Way Matching Service
|
||||||
export { ThreeWayMatchingService, MatchingSearchParams, InvoiceData, InvoiceLineData } from './three-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 {
|
export interface PurchaseSearchParams {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
supplierId?: string;
|
supplierId?: string;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
import { sequencesService, SEQUENCE_CODES } from '../../core/services/sequences.service.js';
|
||||||
import { logger } from '../../shared/utils/logger.js';
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
export type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled';
|
export type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||||
|
|
||||||
export type RfqStatus = 'draft' | 'sent' | 'responded' | 'accepted' | 'rejected' | 'cancelled';
|
export type RfqStatus = 'draft' | 'sent' | 'responded' | 'accepted' | 'rejected' | 'cancelled';
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
import { AuthenticatedRequest } from '../../shared/types/index.js';
|
||||||
import { reportsService } from './reports.service.js';
|
import { reportsService } from './services/index.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// VALIDATION SCHEMAS
|
// 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
|
// Re-export services
|
||||||
export { ReportsService } from './reports.service';
|
export {
|
||||||
export { ReportExecutionService } from './report-execution.service';
|
reportsService,
|
||||||
export { ReportSchedulerService } from './report-scheduler.service';
|
ReportType,
|
||||||
export { DashboardsService } from './dashboards.service';
|
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)
|
// Legacy types and service (kept for backwards compatibility)
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
// Roles module exports
|
// Roles module exports
|
||||||
export { rolesService } from './roles.service.js';
|
export { rolesService } from './services/roles.service.js';
|
||||||
export { permissionsService } from './permissions.service.js';
|
export { permissionsService } from './services/permissions.service.js';
|
||||||
export { rolesController } from './roles.controller.js';
|
export { rolesController } from './roles.controller.js';
|
||||||
export { permissionsController } from './permissions.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';
|
export { default as permissionsRoutes } from './permissions.routes.js';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { CreateRoleDto, UpdateRoleDto, RoleWithPermissions } from './roles.service.js';
|
export type { CreateRoleDto, UpdateRoleDto, RoleWithPermissions } from './services/roles.service.js';
|
||||||
export type { PermissionFilter, EffectivePermission } from './permissions.service.js';
|
export type { PermissionFilter, EffectivePermission } from './services/permissions.service.js';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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 { PermissionAction } from '../auth/entities/index.js';
|
||||||
import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js';
|
import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
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';
|
import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js';
|
||||||
|
|
||||||
// Validation schemas
|
// 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