diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 6630a9d..b5d2994 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -14,7 +14,7 @@ export function createMockRepository() { softDelete: jest.fn(), createQueryBuilder: jest.fn(() => createMockQueryBuilder()), count: jest.fn(), - merge: jest.fn((entity: T, ...sources: Partial[]) => Object.assign(entity, ...sources)), + merge: jest.fn((entity: T, ...sources: Partial[]) => Object.assign(entity as object, ...sources)), }; } diff --git a/src/modules/crm/activities.service.ts b/src/modules/crm/activities.service.ts new file mode 100644 index 0000000..9798e9b --- /dev/null +++ b/src/modules/crm/activities.service.ts @@ -0,0 +1,571 @@ +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 ActivityType = 'call' | 'meeting' | 'email' | 'task' | 'note' | 'other'; +export type ActivityStatus = 'scheduled' | 'done' | 'cancelled'; + +export interface Activity { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + activity_type: ActivityType; + name: string; + description?: string; + user_id?: string; + user_name?: string; + // Polymorphic relations + res_model?: string; // 'opportunity', 'lead', 'partner' + res_id?: string; + res_name?: string; + partner_id?: string; + partner_name?: string; + scheduled_date?: Date; + date_done?: Date; + duration_hours?: number; + status: ActivityStatus; + priority: number; + notes?: string; + created_at: Date; + created_by?: string; +} + +export interface CreateActivityDto { + company_id: string; + activity_type: ActivityType; + name: string; + description?: string; + user_id?: string; + res_model?: string; + res_id?: string; + partner_id?: string; + scheduled_date?: string; + duration_hours?: number; + priority?: number; + notes?: string; +} + +export interface UpdateActivityDto { + activity_type?: ActivityType; + name?: string; + description?: string | null; + user_id?: string | null; + partner_id?: string | null; + scheduled_date?: string | null; + duration_hours?: number | null; + priority?: number; + notes?: string | null; +} + +export interface ActivityFilters { + company_id?: string; + activity_type?: ActivityType; + status?: ActivityStatus; + user_id?: string; + partner_id?: string; + res_model?: string; + res_id?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface ActivitySummary { + total_activities: number; + scheduled: number; + done: number; + cancelled: number; + overdue: number; + by_type: Record; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ActivitiesService { + async findAll(tenantId: string, filters: ActivityFilters = {}): Promise<{ data: Activity[]; total: number }> { + const { company_id, activity_type, status, user_id, partner_id, res_model, res_id, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE a.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND a.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (activity_type) { + whereClause += ` AND a.activity_type = $${paramIndex++}`; + params.push(activity_type); + } + + if (status) { + whereClause += ` AND a.status = $${paramIndex++}`; + params.push(status); + } + + if (user_id) { + whereClause += ` AND a.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (partner_id) { + whereClause += ` AND a.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (res_model) { + whereClause += ` AND a.res_model = $${paramIndex++}`; + params.push(res_model); + } + + if (res_id) { + whereClause += ` AND a.res_id = $${paramIndex++}`; + params.push(res_id); + } + + if (date_from) { + whereClause += ` AND a.scheduled_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND a.scheduled_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.description ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.activities a ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT a.*, + c.name as company_name, + u.name as user_name, + p.name as partner_name + FROM crm.activities a + LEFT JOIN auth.companies c ON a.company_id = c.id + LEFT JOIN auth.users u ON a.user_id = u.id + LEFT JOIN core.partners p ON a.partner_id = p.id + ${whereClause} + ORDER BY + CASE WHEN a.status = 'scheduled' THEN 0 ELSE 1 END, + a.scheduled_date ASC NULLS LAST, + a.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const activity = await queryOne( + `SELECT a.*, + c.name as company_name, + u.name as user_name, + p.name as partner_name + FROM crm.activities a + LEFT JOIN auth.companies c ON a.company_id = c.id + LEFT JOIN auth.users u ON a.user_id = u.id + LEFT JOIN core.partners p ON a.partner_id = p.id + WHERE a.id = $1 AND a.tenant_id = $2`, + [id, tenantId] + ); + + if (!activity) { + throw new NotFoundError('Actividad no encontrada'); + } + + // Get resource name if linked + if (activity.res_model && activity.res_id) { + activity.res_name = await this.getResourceName(activity.res_model, activity.res_id, tenantId); + } + + return activity; + } + + async create(dto: CreateActivityDto, tenantId: string, userId: string): Promise { + const activity = await queryOne( + `INSERT INTO crm.activities ( + tenant_id, company_id, activity_type, name, description, + user_id, res_model, res_id, partner_id, scheduled_date, + duration_hours, priority, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING *`, + [ + tenantId, dto.company_id, dto.activity_type, dto.name, dto.description, + dto.user_id, dto.res_model, dto.res_id, dto.partner_id, dto.scheduled_date, + dto.duration_hours, dto.priority || 1, dto.notes, userId + ] + ); + + logger.info('Activity created', { + activityId: activity?.id, + activityType: dto.activity_type, + resModel: dto.res_model, + resId: dto.res_id, + }); + + // Update date_last_activity on related opportunity/lead + if (dto.res_model && dto.res_id) { + await this.updateLastActivityDate(dto.res_model, dto.res_id, tenantId); + } + + return this.findById(activity!.id, tenantId); + } + + async update(id: string, dto: UpdateActivityDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status === 'done') { + throw new ValidationError('No se pueden editar actividades completadas'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.activity_type !== undefined) { + updateFields.push(`activity_type = $${paramIndex++}`); + values.push(dto.activity_type); + } + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.user_id !== undefined) { + updateFields.push(`user_id = $${paramIndex++}`); + values.push(dto.user_id); + } + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.scheduled_date !== undefined) { + updateFields.push(`scheduled_date = $${paramIndex++}`); + values.push(dto.scheduled_date); + } + if (dto.duration_hours !== undefined) { + updateFields.push(`duration_hours = $${paramIndex++}`); + values.push(dto.duration_hours); + } + if (dto.priority !== undefined) { + updateFields.push(`priority = $${paramIndex++}`); + values.push(dto.priority); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE crm.activities SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async markDone(id: string, tenantId: string, userId: string, notes?: string): Promise { + const activity = await this.findById(id, tenantId); + + if (activity.status === 'done') { + throw new ValidationError('La actividad ya está completada'); + } + + if (activity.status === 'cancelled') { + throw new ValidationError('No se puede completar una actividad cancelada'); + } + + await query( + `UPDATE crm.activities SET + status = 'done', + date_done = CURRENT_TIMESTAMP, + notes = COALESCE($1, notes), + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [notes, userId, id, tenantId] + ); + + // Update date_last_activity on related opportunity/lead + if (activity.res_model && activity.res_id) { + await this.updateLastActivityDate(activity.res_model, activity.res_id, tenantId); + } + + logger.info('Activity marked as done', { + activityId: id, + activityType: activity.activity_type, + }); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const activity = await this.findById(id, tenantId); + + if (activity.status === 'done') { + throw new ValidationError('No se puede cancelar una actividad completada'); + } + + if (activity.status === 'cancelled') { + throw new ValidationError('La actividad ya está cancelada'); + } + + await query( + `UPDATE crm.activities SET + status = 'cancelled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const activity = await this.findById(id, tenantId); + + if (activity.status === 'done') { + throw new ValidationError('No se pueden eliminar actividades completadas'); + } + + await query( + `DELETE FROM crm.activities WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + /** + * Get activities for a specific resource (opportunity, lead, partner) + */ + async getResourceActivities( + resModel: string, + resId: string, + tenantId: string, + status?: ActivityStatus + ): Promise { + let whereClause = 'WHERE a.res_model = $1 AND a.res_id = $2 AND a.tenant_id = $3'; + const params: any[] = [resModel, resId, tenantId]; + + if (status) { + whereClause += ' AND a.status = $4'; + params.push(status); + } + + return query( + `SELECT a.*, + u.name as user_name, + p.name as partner_name + FROM crm.activities a + LEFT JOIN auth.users u ON a.user_id = u.id + LEFT JOIN core.partners p ON a.partner_id = p.id + ${whereClause} + ORDER BY a.scheduled_date ASC NULLS LAST, a.created_at DESC`, + params + ); + } + + /** + * Get activity summary for dashboard + */ + async getActivitySummary( + tenantId: string, + userId?: string, + dateFrom?: string, + dateTo?: string + ): Promise { + let whereClause = 'WHERE tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (userId) { + whereClause += ` AND user_id = $${paramIndex++}`; + params.push(userId); + } + + if (dateFrom) { + whereClause += ` AND scheduled_date >= $${paramIndex++}`; + params.push(dateFrom); + } + + if (dateTo) { + whereClause += ` AND scheduled_date <= $${paramIndex++}`; + params.push(dateTo); + } + + const result = await queryOne<{ + total: string; + scheduled: string; + done: string; + cancelled: string; + overdue: string; + }>( + `SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'scheduled') as scheduled, + COUNT(*) FILTER (WHERE status = 'done') as done, + COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled, + COUNT(*) FILTER (WHERE status = 'scheduled' AND scheduled_date < CURRENT_DATE) as overdue + FROM crm.activities + ${whereClause}`, + params + ); + + const byTypeResult = await query<{ activity_type: ActivityType; count: string }>( + `SELECT activity_type, COUNT(*) as count + FROM crm.activities + ${whereClause} + GROUP BY activity_type`, + params + ); + + const byType: Record = { + call: 0, + meeting: 0, + email: 0, + task: 0, + note: 0, + other: 0, + }; + + for (const row of byTypeResult) { + byType[row.activity_type] = parseInt(row.count, 10); + } + + return { + total_activities: parseInt(result?.total || '0', 10), + scheduled: parseInt(result?.scheduled || '0', 10), + done: parseInt(result?.done || '0', 10), + cancelled: parseInt(result?.cancelled || '0', 10), + overdue: parseInt(result?.overdue || '0', 10), + by_type: byType, + }; + } + + /** + * Schedule a follow-up activity after completing one + */ + async scheduleFollowUp( + completedActivityId: string, + followUpDto: CreateActivityDto, + tenantId: string, + userId: string + ): Promise { + const completedActivity = await this.findById(completedActivityId, tenantId); + + // Inherit resource info from completed activity if not specified + const dto = { + ...followUpDto, + res_model: followUpDto.res_model || completedActivity.res_model, + res_id: followUpDto.res_id || completedActivity.res_id, + partner_id: followUpDto.partner_id || completedActivity.partner_id, + }; + + return this.create(dto, tenantId, userId); + } + + /** + * Get overdue activities count for notifications + */ + async getOverdueCount(tenantId: string, userId?: string): Promise { + let whereClause = 'WHERE tenant_id = $1 AND status = \'scheduled\' AND scheduled_date < CURRENT_DATE'; + const params: any[] = [tenantId]; + + if (userId) { + whereClause += ' AND user_id = $2'; + params.push(userId); + } + + const result = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.activities ${whereClause}`, + params + ); + + return parseInt(result?.count || '0', 10); + } + + private async getResourceName(resModel: string, resId: string, tenantId: string): Promise { + let tableName: string; + switch (resModel) { + case 'opportunity': + tableName = 'crm.opportunities'; + break; + case 'lead': + tableName = 'crm.leads'; + break; + case 'partner': + tableName = 'core.partners'; + break; + default: + return ''; + } + + const result = await queryOne<{ name: string }>( + `SELECT name FROM ${tableName} WHERE id = $1 AND tenant_id = $2`, + [resId, tenantId] + ); + + return result?.name || ''; + } + + private async updateLastActivityDate(resModel: string, resId: string, tenantId: string): Promise { + let tableName: string; + switch (resModel) { + case 'opportunity': + tableName = 'crm.opportunities'; + break; + case 'lead': + tableName = 'crm.leads'; + break; + default: + return; + } + + await query( + `UPDATE ${tableName} SET date_last_activity = CURRENT_TIMESTAMP WHERE id = $1 AND tenant_id = $2`, + [resId, tenantId] + ); + } +} + +export const activitiesService = new ActivitiesService(); diff --git a/src/modules/crm/forecasting.service.ts b/src/modules/crm/forecasting.service.ts new file mode 100644 index 0000000..bcfaeca --- /dev/null +++ b/src/modules/crm/forecasting.service.ts @@ -0,0 +1,452 @@ +import { query, queryOne } from '../../config/database.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ForecastPeriod { + period: string; // YYYY-MM or YYYY-QN + expected_revenue: number; + weighted_revenue: number; + opportunity_count: number; + avg_probability: number; + won_revenue?: number; + won_count?: number; + lost_revenue?: number; + lost_count?: number; +} + +export interface SalesForecast { + total_pipeline: number; + weighted_pipeline: number; + expected_close_this_month: number; + expected_close_this_quarter: number; + opportunities_count: number; + avg_deal_size: number; + avg_probability: number; + periods: ForecastPeriod[]; +} + +export interface WinLossAnalysis { + period: string; + won_count: number; + won_revenue: number; + lost_count: number; + lost_revenue: number; + win_rate: number; + avg_won_deal_size: number; + avg_lost_deal_size: number; +} + +export interface PipelineMetrics { + total_opportunities: number; + total_value: number; + by_stage: { + stage_id: string; + stage_name: string; + sequence: number; + count: number; + value: number; + weighted_value: number; + avg_probability: number; + }[]; + by_user: { + user_id: string; + user_name: string; + count: number; + value: number; + weighted_value: number; + }[]; + avg_days_in_stage: number; + avg_sales_cycle_days: number; +} + +export interface ForecastFilters { + company_id?: string; + user_id?: string; + sales_team_id?: string; + date_from?: string; + date_to?: string; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ForecastingService { + /** + * Get sales forecast for the pipeline + */ + async getSalesForecast( + tenantId: string, + filters: ForecastFilters = {} + ): Promise { + const { company_id, user_id, sales_team_id } = filters; + + let whereClause = `WHERE o.tenant_id = $1 AND o.status = 'open'`; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND o.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (user_id) { + whereClause += ` AND o.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (sales_team_id) { + whereClause += ` AND o.sales_team_id = $${paramIndex++}`; + params.push(sales_team_id); + } + + // Get overall metrics + const metrics = await queryOne<{ + total_pipeline: string; + weighted_pipeline: string; + count: string; + avg_probability: string; + }>( + `SELECT + COALESCE(SUM(expected_revenue), 0) as total_pipeline, + COALESCE(SUM(expected_revenue * probability / 100), 0) as weighted_pipeline, + COUNT(*) as count, + COALESCE(AVG(probability), 0) as avg_probability + FROM crm.opportunities o + ${whereClause}`, + params + ); + + // Get expected close this month + const thisMonthParams = [...params]; + const thisMonth = await queryOne<{ expected: string }>( + `SELECT COALESCE(SUM(expected_revenue * probability / 100), 0) as expected + FROM crm.opportunities o + ${whereClause} + AND date_deadline >= DATE_TRUNC('month', CURRENT_DATE) + AND date_deadline < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'`, + thisMonthParams + ); + + // Get expected close this quarter + const thisQuarterParams = [...params]; + const thisQuarter = await queryOne<{ expected: string }>( + `SELECT COALESCE(SUM(expected_revenue * probability / 100), 0) as expected + FROM crm.opportunities o + ${whereClause} + AND date_deadline >= DATE_TRUNC('quarter', CURRENT_DATE) + AND date_deadline < DATE_TRUNC('quarter', CURRENT_DATE) + INTERVAL '3 months'`, + thisQuarterParams + ); + + // Get periods (next 6 months) + const periods = await query( + `SELECT + TO_CHAR(DATE_TRUNC('month', COALESCE(date_deadline, CURRENT_DATE + INTERVAL '1 month')), 'YYYY-MM') as period, + COALESCE(SUM(expected_revenue), 0) as expected_revenue, + COALESCE(SUM(expected_revenue * probability / 100), 0) as weighted_revenue, + COUNT(*) as opportunity_count, + COALESCE(AVG(probability), 0) as avg_probability + FROM crm.opportunities o + ${whereClause} + AND (date_deadline IS NULL OR date_deadline >= CURRENT_DATE) + AND (date_deadline IS NULL OR date_deadline < CURRENT_DATE + INTERVAL '6 months') + GROUP BY DATE_TRUNC('month', COALESCE(date_deadline, CURRENT_DATE + INTERVAL '1 month')) + ORDER BY period`, + params + ); + + const totalPipeline = parseFloat(metrics?.total_pipeline || '0'); + const count = parseInt(metrics?.count || '0', 10); + + return { + total_pipeline: totalPipeline, + weighted_pipeline: parseFloat(metrics?.weighted_pipeline || '0'), + expected_close_this_month: parseFloat(thisMonth?.expected || '0'), + expected_close_this_quarter: parseFloat(thisQuarter?.expected || '0'), + opportunities_count: count, + avg_deal_size: count > 0 ? totalPipeline / count : 0, + avg_probability: parseFloat(metrics?.avg_probability || '0'), + periods, + }; + } + + /** + * Get win/loss analysis for reporting + */ + async getWinLossAnalysis( + tenantId: string, + filters: ForecastFilters = {}, + periodType: 'month' | 'quarter' | 'year' = 'month' + ): Promise { + const { company_id, user_id, sales_team_id, date_from, date_to } = filters; + + let whereClause = `WHERE o.tenant_id = $1 AND o.status IN ('won', 'lost')`; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND o.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (user_id) { + whereClause += ` AND o.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (sales_team_id) { + whereClause += ` AND o.sales_team_id = $${paramIndex++}`; + params.push(sales_team_id); + } + + if (date_from) { + whereClause += ` AND o.date_closed >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND o.date_closed <= $${paramIndex++}`; + params.push(date_to); + } + + const periodTrunc = periodType === 'year' ? 'year' : periodType === 'quarter' ? 'quarter' : 'month'; + const periodFormat = periodType === 'year' ? 'YYYY' : periodType === 'quarter' ? 'YYYY-"Q"Q' : 'YYYY-MM'; + + return query( + `SELECT + TO_CHAR(DATE_TRUNC('${periodTrunc}', date_closed), '${periodFormat}') as period, + COUNT(*) FILTER (WHERE status = 'won') as won_count, + COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) as won_revenue, + COUNT(*) FILTER (WHERE status = 'lost') as lost_count, + COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'lost'), 0) as lost_revenue, + CASE + WHEN COUNT(*) > 0 + THEN ROUND(COUNT(*) FILTER (WHERE status = 'won')::numeric / COUNT(*) * 100, 2) + ELSE 0 + END as win_rate, + CASE + WHEN COUNT(*) FILTER (WHERE status = 'won') > 0 + THEN COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) / COUNT(*) FILTER (WHERE status = 'won') + ELSE 0 + END as avg_won_deal_size, + CASE + WHEN COUNT(*) FILTER (WHERE status = 'lost') > 0 + THEN COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'lost'), 0) / COUNT(*) FILTER (WHERE status = 'lost') + ELSE 0 + END as avg_lost_deal_size + FROM crm.opportunities o + ${whereClause} + GROUP BY DATE_TRUNC('${periodTrunc}', date_closed) + ORDER BY period DESC`, + params + ); + } + + /** + * Get pipeline metrics for dashboard + */ + async getPipelineMetrics( + tenantId: string, + filters: ForecastFilters = {} + ): Promise { + const { company_id, user_id, sales_team_id } = filters; + + let whereClause = `WHERE o.tenant_id = $1 AND o.status = 'open'`; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND o.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (user_id) { + whereClause += ` AND o.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (sales_team_id) { + whereClause += ` AND o.sales_team_id = $${paramIndex++}`; + params.push(sales_team_id); + } + + // Get totals + const totals = await queryOne<{ count: string; total: string }>( + `SELECT COUNT(*) as count, COALESCE(SUM(expected_revenue), 0) as total + FROM crm.opportunities o ${whereClause}`, + params + ); + + // Get by stage + const byStage = await query<{ + stage_id: string; + stage_name: string; + sequence: number; + count: string; + value: string; + weighted_value: string; + avg_probability: string; + }>( + `SELECT + s.id as stage_id, + s.name as stage_name, + s.sequence, + COUNT(o.id) as count, + COALESCE(SUM(o.expected_revenue), 0) as value, + COALESCE(SUM(o.expected_revenue * o.probability / 100), 0) as weighted_value, + COALESCE(AVG(o.probability), 0) as avg_probability + FROM crm.stages s + LEFT JOIN crm.opportunities o ON o.stage_id = s.id AND o.status = 'open' AND o.tenant_id = $1 + WHERE s.tenant_id = $1 AND s.active = true + GROUP BY s.id, s.name, s.sequence + ORDER BY s.sequence`, + [tenantId] + ); + + // Get by user + const byUser = await query<{ + user_id: string; + user_name: string; + count: string; + value: string; + weighted_value: string; + }>( + `SELECT + u.id as user_id, + u.name as user_name, + COUNT(o.id) as count, + COALESCE(SUM(o.expected_revenue), 0) as value, + COALESCE(SUM(o.expected_revenue * o.probability / 100), 0) as weighted_value + FROM crm.opportunities o + JOIN auth.users u ON o.user_id = u.id + ${whereClause} + GROUP BY u.id, u.name + ORDER BY weighted_value DESC`, + params + ); + + // Get average sales cycle + const cycleStats = await queryOne<{ avg_days: string }>( + `SELECT AVG(EXTRACT(EPOCH FROM (date_closed - created_at)) / 86400) as avg_days + FROM crm.opportunities o + WHERE o.tenant_id = $1 AND o.status = 'won' AND date_closed IS NOT NULL`, + [tenantId] + ); + + return { + total_opportunities: parseInt(totals?.count || '0', 10), + total_value: parseFloat(totals?.total || '0'), + by_stage: byStage.map(s => ({ + stage_id: s.stage_id, + stage_name: s.stage_name, + sequence: s.sequence, + count: parseInt(s.count, 10), + value: parseFloat(s.value), + weighted_value: parseFloat(s.weighted_value), + avg_probability: parseFloat(s.avg_probability), + })), + by_user: byUser.map(u => ({ + user_id: u.user_id, + user_name: u.user_name, + count: parseInt(u.count, 10), + value: parseFloat(u.value), + weighted_value: parseFloat(u.weighted_value), + })), + avg_days_in_stage: 0, // Would need stage history tracking + avg_sales_cycle_days: parseFloat(cycleStats?.avg_days || '0'), + }; + } + + /** + * Get user performance metrics + */ + async getUserPerformance( + tenantId: string, + userId: string, + dateFrom?: string, + dateTo?: string + ): Promise<{ + open_opportunities: number; + pipeline_value: number; + won_deals: number; + won_revenue: number; + lost_deals: number; + win_rate: number; + activities_done: number; + avg_deal_size: number; + }> { + let whereClause = `WHERE o.tenant_id = $1 AND o.user_id = $2`; + const params: any[] = [tenantId, userId]; + let paramIndex = 3; + + if (dateFrom) { + whereClause += ` AND o.created_at >= $${paramIndex++}`; + params.push(dateFrom); + } + + if (dateTo) { + whereClause += ` AND o.created_at <= $${paramIndex++}`; + params.push(dateTo); + } + + const metrics = await queryOne<{ + open_count: string; + pipeline: string; + won_count: string; + won_revenue: string; + lost_count: string; + }>( + `SELECT + COUNT(*) FILTER (WHERE status = 'open') as open_count, + COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'open'), 0) as pipeline, + COUNT(*) FILTER (WHERE status = 'won') as won_count, + COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) as won_revenue, + COUNT(*) FILTER (WHERE status = 'lost') as lost_count + FROM crm.opportunities o + ${whereClause}`, + params + ); + + // Get activities count + let activityWhere = `WHERE tenant_id = $1 AND user_id = $2 AND status = 'done'`; + const activityParams: any[] = [tenantId, userId]; + let actParamIndex = 3; + + if (dateFrom) { + activityWhere += ` AND date_done >= $${actParamIndex++}`; + activityParams.push(dateFrom); + } + + if (dateTo) { + activityWhere += ` AND date_done <= $${actParamIndex++}`; + activityParams.push(dateTo); + } + + const activityCount = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.activities ${activityWhere}`, + activityParams + ); + + const wonCount = parseInt(metrics?.won_count || '0', 10); + const lostCount = parseInt(metrics?.lost_count || '0', 10); + const wonRevenue = parseFloat(metrics?.won_revenue || '0'); + const totalDeals = wonCount + lostCount; + + return { + open_opportunities: parseInt(metrics?.open_count || '0', 10), + pipeline_value: parseFloat(metrics?.pipeline || '0'), + won_deals: wonCount, + won_revenue: wonRevenue, + lost_deals: lostCount, + win_rate: totalDeals > 0 ? (wonCount / totalDeals) * 100 : 0, + activities_done: parseInt(activityCount?.count || '0', 10), + avg_deal_size: wonCount > 0 ? wonRevenue / wonCount : 0, + }; + } +} + +export const forecastingService = new ForecastingService(); diff --git a/src/modules/crm/index.ts b/src/modules/crm/index.ts index 51e42d6..1120038 100644 --- a/src/modules/crm/index.ts +++ b/src/modules/crm/index.ts @@ -1,5 +1,7 @@ export * from './leads.service.js'; export * from './opportunities.service.js'; export * from './stages.service.js'; +export * from './activities.service.js'; +export * from './forecasting.service.js'; export * from './crm.controller.js'; export { default as crmRoutes } from './crm.routes.js'; diff --git a/src/modules/financial/entities/account-mapping.entity.ts b/src/modules/financial/entities/account-mapping.entity.ts new file mode 100644 index 0000000..31cc474 --- /dev/null +++ b/src/modules/financial/entities/account-mapping.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +/** + * Account Mapping Entity + * + * Maps document types and operations to GL accounts. + * Used by GL Posting Service to automatically create journal entries. + * + * Example mappings: + * - Customer Invoice -> AR Account (debit), Sales Revenue (credit) + * - Supplier Invoice -> AP Account (credit), Expense Account (debit) + * - Payment Received -> Cash Account (debit), AR Account (credit) + */ +export enum AccountMappingType { + CUSTOMER_INVOICE = 'customer_invoice', + SUPPLIER_INVOICE = 'supplier_invoice', + CUSTOMER_PAYMENT = 'customer_payment', + SUPPLIER_PAYMENT = 'supplier_payment', + SALES_REVENUE = 'sales_revenue', + PURCHASE_EXPENSE = 'purchase_expense', + TAX_PAYABLE = 'tax_payable', + TAX_RECEIVABLE = 'tax_receivable', + INVENTORY_ASSET = 'inventory_asset', + COST_OF_GOODS_SOLD = 'cost_of_goods_sold', +} + +@Entity({ name: 'account_mappings', schema: 'financial' }) +@Index('idx_account_mappings_tenant_id', ['tenantId']) +@Index('idx_account_mappings_company_id', ['companyId']) +@Index('idx_account_mappings_type', ['mappingType']) +@Index('idx_account_mappings_unique', ['tenantId', 'companyId', 'mappingType'], { unique: true }) +export class AccountMapping { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'mapping_type', type: 'varchar', length: 50 }) + mappingType: AccountMappingType | string; + + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; +} diff --git a/src/modules/financial/entities/index.ts b/src/modules/financial/entities/index.ts index a142e49..df67f1c 100644 --- a/src/modules/financial/entities/index.ts +++ b/src/modules/financial/entities/index.ts @@ -1,6 +1,7 @@ // Account entities export { AccountType, AccountTypeEnum } from './account-type.entity.js'; export { Account } from './account.entity.js'; +export { AccountMapping, AccountMappingType } from './account-mapping.entity.js'; // Journal entities export { Journal, JournalType } from './journal.entity.js'; diff --git a/src/modules/financial/gl-posting.service.ts b/src/modules/financial/gl-posting.service.ts new file mode 100644 index 0000000..cab3171 --- /dev/null +++ b/src/modules/financial/gl-posting.service.ts @@ -0,0 +1,711 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { AccountMappingType } from './entities/account-mapping.entity.js'; +import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface AccountMapping { + id: string; + tenant_id: string; + company_id: string; + mapping_type: AccountMappingType | string; + account_id: string; + account_code?: string; + account_name?: string; + description: string | null; + is_active: boolean; +} + +export interface JournalEntryLineInput { + account_id: string; + partner_id?: string; + debit: number; + credit: number; + description?: string; + ref?: string; +} + +export interface PostingResult { + journal_entry_id: string; + journal_entry_name: string; + total_debit: number; + total_credit: number; + lines_count: number; +} + +export interface InvoiceForPosting { + id: string; + tenant_id: string; + company_id: string; + partner_id: string; + partner_name?: string; + invoice_type: 'customer' | 'supplier'; + number: string; + invoice_date: Date; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + journal_id?: string; + lines: InvoiceLineForPosting[]; +} + +export interface InvoiceLineForPosting { + id: string; + product_id?: string; + description: string; + quantity: number; + price_unit: number; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + account_id?: string; + tax_ids: string[]; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class GLPostingService { + /** + * Get account mapping for a specific type and company + */ + async getMapping( + mappingType: AccountMappingType | string, + tenantId: string, + companyId: string + ): Promise { + const mapping = await queryOne( + `SELECT am.*, a.code as account_code, a.name as account_name + FROM financial.account_mappings am + LEFT JOIN financial.accounts a ON am.account_id = a.id + WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.mapping_type = $3 AND am.is_active = true`, + [tenantId, companyId, mappingType] + ); + return mapping; + } + + /** + * Get all active mappings for a company + */ + async getMappings(tenantId: string, companyId: string): Promise { + return query( + `SELECT am.*, a.code as account_code, a.name as account_name + FROM financial.account_mappings am + LEFT JOIN financial.accounts a ON am.account_id = a.id + WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.is_active = true + ORDER BY am.mapping_type`, + [tenantId, companyId] + ); + } + + /** + * Create or update an account mapping + */ + async setMapping( + mappingType: AccountMappingType | string, + accountId: string, + tenantId: string, + companyId: string, + description?: string, + userId?: string + ): Promise { + const result = await queryOne( + `INSERT INTO financial.account_mappings (tenant_id, company_id, mapping_type, account_id, description, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (tenant_id, company_id, mapping_type) + DO UPDATE SET account_id = $4, description = $5, updated_by = $6, updated_at = CURRENT_TIMESTAMP + RETURNING *`, + [tenantId, companyId, mappingType, accountId, description, userId] + ); + return result!; + } + + /** + * Create a journal entry from a validated invoice + * + * For customer invoice (sale): + * - Debit: Accounts Receivable (partner balance) + * - Credit: Sales Revenue (per line or default mapping) + * - Credit: Tax Payable (if taxes apply) + * + * For supplier invoice (bill): + * - Credit: Accounts Payable (partner balance) + * - Debit: Purchase Expense (per line or default mapping) + * - Debit: Tax Receivable (if taxes apply) + */ + async createInvoicePosting( + invoice: InvoiceForPosting, + userId: string + ): Promise { + const { tenant_id: tenantId, company_id: companyId } = invoice; + + logger.info('Creating GL posting for invoice', { + invoiceId: invoice.id, + invoiceNumber: invoice.number, + invoiceType: invoice.invoice_type, + }); + + // Validate invoice has lines + if (!invoice.lines || invoice.lines.length === 0) { + throw new ValidationError('La factura debe tener al menos una línea para contabilizar'); + } + + // Get required account mappings based on invoice type + const isCustomerInvoice = invoice.invoice_type === 'customer'; + + // Get receivable/payable account + const partnerAccountType = isCustomerInvoice + ? AccountMappingType.CUSTOMER_INVOICE + : AccountMappingType.SUPPLIER_INVOICE; + const partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId); + + if (!partnerMapping) { + throw new ValidationError( + `No hay cuenta configurada para ${isCustomerInvoice ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}. Configure account_mappings.` + ); + } + + // Get default revenue/expense account + const revenueExpenseType = isCustomerInvoice + ? AccountMappingType.SALES_REVENUE + : AccountMappingType.PURCHASE_EXPENSE; + const revenueExpenseMapping = await this.getMapping(revenueExpenseType, tenantId, companyId); + + // Get tax accounts if there are taxes + let taxPayableMapping: AccountMapping | null = null; + let taxReceivableMapping: AccountMapping | null = null; + + if (invoice.amount_tax > 0) { + if (isCustomerInvoice) { + taxPayableMapping = await this.getMapping(AccountMappingType.TAX_PAYABLE, tenantId, companyId); + if (!taxPayableMapping) { + throw new ValidationError('No hay cuenta configurada para IVA por Pagar'); + } + } else { + taxReceivableMapping = await this.getMapping(AccountMappingType.TAX_RECEIVABLE, tenantId, companyId); + if (!taxReceivableMapping) { + throw new ValidationError('No hay cuenta configurada para IVA por Recuperar'); + } + } + } + + // Build journal entry lines + const jeLines: JournalEntryLineInput[] = []; + + // Line 1: Partner account (AR/AP) + if (isCustomerInvoice) { + // Customer invoice: Debit AR + jeLines.push({ + account_id: partnerMapping.account_id, + partner_id: invoice.partner_id, + debit: invoice.amount_total, + credit: 0, + description: `Factura ${invoice.number} - ${invoice.partner_name || 'Cliente'}`, + ref: invoice.number, + }); + } else { + // Supplier invoice: Credit AP + jeLines.push({ + account_id: partnerMapping.account_id, + partner_id: invoice.partner_id, + debit: 0, + credit: invoice.amount_total, + description: `Factura ${invoice.number} - ${invoice.partner_name || 'Proveedor'}`, + ref: invoice.number, + }); + } + + // Lines for each invoice line (revenue/expense) + for (const line of invoice.lines) { + // Use line's account_id if specified, otherwise use default mapping + const lineAccountId = line.account_id || revenueExpenseMapping?.account_id; + + if (!lineAccountId) { + throw new ValidationError( + `No hay cuenta de ${isCustomerInvoice ? 'ingresos' : 'gastos'} configurada para la línea: ${line.description}` + ); + } + + if (isCustomerInvoice) { + // Customer invoice: Credit Revenue + jeLines.push({ + account_id: lineAccountId, + debit: 0, + credit: line.amount_untaxed, + description: line.description, + ref: invoice.number, + }); + } else { + // Supplier invoice: Debit Expense + jeLines.push({ + account_id: lineAccountId, + debit: line.amount_untaxed, + credit: 0, + description: line.description, + ref: invoice.number, + }); + } + } + + // Tax line if applicable + if (invoice.amount_tax > 0) { + if (isCustomerInvoice && taxPayableMapping) { + // Customer invoice: Credit Tax Payable + jeLines.push({ + account_id: taxPayableMapping.account_id, + debit: 0, + credit: invoice.amount_tax, + description: `IVA - Factura ${invoice.number}`, + ref: invoice.number, + }); + } else if (!isCustomerInvoice && taxReceivableMapping) { + // Supplier invoice: Debit Tax Receivable + jeLines.push({ + account_id: taxReceivableMapping.account_id, + debit: invoice.amount_tax, + credit: 0, + description: `IVA - Factura ${invoice.number}`, + ref: invoice.number, + }); + } + } + + // Validate balance + const totalDebit = jeLines.reduce((sum, l) => sum + l.debit, 0); + const totalCredit = jeLines.reduce((sum, l) => sum + l.credit, 0); + + if (Math.abs(totalDebit - totalCredit) > 0.01) { + logger.error('Journal entry not balanced', { + invoiceId: invoice.id, + totalDebit, + totalCredit, + difference: totalDebit - totalCredit, + }); + throw new ValidationError( + `El asiento contable no está balanceado. Débitos: ${totalDebit.toFixed(2)}, Créditos: ${totalCredit.toFixed(2)}` + ); + } + + // Get journal (use invoice's journal or find default) + let journalId = invoice.journal_id; + if (!journalId) { + const journalType = isCustomerInvoice ? 'sale' : 'purchase'; + const defaultJournal = await queryOne<{ id: string }>( + `SELECT id FROM financial.journals + WHERE tenant_id = $1 AND company_id = $2 AND type = $3 AND is_active = true + LIMIT 1`, + [tenantId, companyId, journalType] + ); + + if (!defaultJournal) { + throw new ValidationError( + `No hay diario de ${isCustomerInvoice ? 'ventas' : 'compras'} configurado` + ); + } + journalId = defaultJournal.id; + } + + // Create journal entry + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Generate journal entry number + const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId); + + // Create entry header + const entryResult = await client.query( + `INSERT INTO financial.journal_entries ( + tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8) + RETURNING id, name`, + [ + tenantId, + companyId, + journalId, + jeName, + invoice.number, + invoice.invoice_date, + `Asiento automático - Factura ${invoice.number}`, + userId, + ] + ); + const journalEntry = entryResult.rows[0]; + + // Create entry lines + for (const line of jeLines) { + await client.query( + `INSERT INTO financial.journal_entry_lines ( + entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + journalEntry.id, + tenantId, + line.account_id, + line.partner_id, + line.debit, + line.credit, + line.description, + line.ref, + ] + ); + } + + // Update journal entry posted_at + await client.query( + `UPDATE financial.journal_entries SET posted_at = CURRENT_TIMESTAMP, posted_by = $1 WHERE id = $2`, + [userId, journalEntry.id] + ); + + await client.query('COMMIT'); + + logger.info('GL posting created successfully', { + invoiceId: invoice.id, + journalEntryId: journalEntry.id, + journalEntryName: journalEntry.name, + totalDebit, + totalCredit, + linesCount: jeLines.length, + }); + + return { + journal_entry_id: journalEntry.id, + journal_entry_name: journalEntry.name, + total_debit: totalDebit, + total_credit: totalCredit, + lines_count: jeLines.length, + }; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error creating GL posting', { + invoiceId: invoice.id, + error: (error as Error).message, + }); + throw error; + } finally { + client.release(); + } + } + + /** + * Create a journal entry from a posted payment + * + * For inbound payment (from customer): + * - Debit: Cash/Bank account + * - Credit: Accounts Receivable + * + * For outbound payment (to supplier): + * - Credit: Cash/Bank account + * - Debit: Accounts Payable + */ + async createPaymentPosting( + payment: { + id: string; + tenant_id: string; + company_id: string; + partner_id: string; + partner_name?: string; + payment_type: 'inbound' | 'outbound'; + amount: number; + payment_date: Date; + ref?: string; + journal_id: string; + }, + userId: string, + client?: PoolClient + ): Promise { + const { tenant_id: tenantId, company_id: companyId } = payment; + const isInbound = payment.payment_type === 'inbound'; + + logger.info('Creating GL posting for payment', { + paymentId: payment.id, + paymentType: payment.payment_type, + amount: payment.amount, + }); + + // Get cash/bank account from journal + const journal = await queryOne<{ default_debit_account_id: string; default_credit_account_id: string }>( + `SELECT default_debit_account_id, default_credit_account_id FROM financial.journals WHERE id = $1`, + [payment.journal_id] + ); + + if (!journal) { + throw new ValidationError('Diario de pago no encontrado'); + } + + const cashAccountId = isInbound ? journal.default_debit_account_id : journal.default_credit_account_id; + if (!cashAccountId) { + throw new ValidationError('El diario no tiene cuenta de efectivo/banco configurada'); + } + + // Get AR/AP account + const partnerAccountType = isInbound + ? AccountMappingType.CUSTOMER_PAYMENT + : AccountMappingType.SUPPLIER_PAYMENT; + let partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId); + + // Fall back to invoice mapping if payment-specific not configured + if (!partnerMapping) { + const fallbackType = isInbound + ? AccountMappingType.CUSTOMER_INVOICE + : AccountMappingType.SUPPLIER_INVOICE; + partnerMapping = await this.getMapping(fallbackType, tenantId, companyId); + } + + if (!partnerMapping) { + throw new ValidationError( + `No hay cuenta configurada para ${isInbound ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}` + ); + } + + // Build journal entry lines + const jeLines: JournalEntryLineInput[] = []; + const paymentRef = payment.ref || `PAY-${payment.id.substring(0, 8)}`; + + if (isInbound) { + // Inbound: Debit Cash, Credit AR + jeLines.push({ + account_id: cashAccountId, + debit: payment.amount, + credit: 0, + description: `Pago recibido - ${payment.partner_name || 'Cliente'}`, + ref: paymentRef, + }); + jeLines.push({ + account_id: partnerMapping.account_id, + partner_id: payment.partner_id, + debit: 0, + credit: payment.amount, + description: `Pago recibido - ${payment.partner_name || 'Cliente'}`, + ref: paymentRef, + }); + } else { + // Outbound: Credit Cash, Debit AP + jeLines.push({ + account_id: cashAccountId, + debit: 0, + credit: payment.amount, + description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`, + ref: paymentRef, + }); + jeLines.push({ + account_id: partnerMapping.account_id, + partner_id: payment.partner_id, + debit: payment.amount, + credit: 0, + description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`, + ref: paymentRef, + }); + } + + // Create journal entry + const ownClient = !client; + const dbClient = client || await getClient(); + + try { + if (ownClient) { + await dbClient.query('BEGIN'); + } + + // Generate journal entry number + const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId); + + // Create entry header + const entryResult = await dbClient.query( + `INSERT INTO financial.journal_entries ( + tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8, CURRENT_TIMESTAMP, $8) + RETURNING id, name`, + [ + tenantId, + companyId, + payment.journal_id, + jeName, + paymentRef, + payment.payment_date, + `Asiento automático - Pago ${paymentRef}`, + userId, + ] + ); + const journalEntry = entryResult.rows[0]; + + // Create entry lines + for (const line of jeLines) { + await dbClient.query( + `INSERT INTO financial.journal_entry_lines ( + entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + journalEntry.id, + tenantId, + line.account_id, + line.partner_id, + line.debit, + line.credit, + line.description, + line.ref, + ] + ); + } + + if (ownClient) { + await dbClient.query('COMMIT'); + } + + logger.info('Payment GL posting created successfully', { + paymentId: payment.id, + journalEntryId: journalEntry.id, + journalEntryName: journalEntry.name, + }); + + return { + journal_entry_id: journalEntry.id, + journal_entry_name: journalEntry.name, + total_debit: payment.amount, + total_credit: payment.amount, + lines_count: 2, + }; + } catch (error) { + if (ownClient) { + await dbClient.query('ROLLBACK'); + } + throw error; + } finally { + if (ownClient) { + dbClient.release(); + } + } + } + + /** + * Reverse a journal entry (create a contra entry) + */ + async reversePosting( + journalEntryId: string, + tenantId: string, + reason: string, + userId: string + ): Promise { + // Get original entry + const originalEntry = await queryOne<{ + id: string; + company_id: string; + journal_id: string; + name: string; + ref: string; + date: Date; + }>( + `SELECT id, company_id, journal_id, name, ref, date + FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`, + [journalEntryId, tenantId] + ); + + if (!originalEntry) { + throw new NotFoundError('Asiento contable no encontrado'); + } + + // Get original lines + const originalLines = await query( + `SELECT account_id, partner_id, debit, credit, description, ref + FROM financial.journal_entry_lines WHERE entry_id = $1`, + [journalEntryId] + ); + + // Reverse debits and credits + const reversedLines: JournalEntryLineInput[] = originalLines.map(line => ({ + account_id: line.account_id, + partner_id: line.partner_id, + debit: line.credit, // Swap + credit: line.debit, // Swap + description: `Reverso: ${line.description || ''}`, + ref: line.ref, + })); + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Generate new entry number + const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId); + + // Create reversal entry + const entryResult = await client.query( + `INSERT INTO financial.journal_entries ( + tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by + ) + VALUES ($1, $2, $3, $4, $5, CURRENT_DATE, $6, 'posted', $7, CURRENT_TIMESTAMP, $7) + RETURNING id, name`, + [ + tenantId, + originalEntry.company_id, + originalEntry.journal_id, + jeName, + `REV-${originalEntry.name}`, + `Reverso de ${originalEntry.name}: ${reason}`, + userId, + ] + ); + const reversalEntry = entryResult.rows[0]; + + // Create reversal lines + for (const line of reversedLines) { + await client.query( + `INSERT INTO financial.journal_entry_lines ( + entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + reversalEntry.id, + tenantId, + line.account_id, + line.partner_id, + line.debit, + line.credit, + line.description, + line.ref, + ] + ); + } + + // Mark original as cancelled + await client.query( + `UPDATE financial.journal_entries SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1 WHERE id = $2`, + [userId, journalEntryId] + ); + + await client.query('COMMIT'); + + const totalDebit = reversedLines.reduce((sum, l) => sum + l.debit, 0); + + logger.info('GL posting reversed', { + originalEntryId: journalEntryId, + reversalEntryId: reversalEntry.id, + reason, + }); + + return { + journal_entry_id: reversalEntry.id, + journal_entry_name: reversalEntry.name, + total_debit: totalDebit, + total_credit: totalDebit, + lines_count: reversedLines.length, + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } +} + +export const glPostingService = new GLPostingService(); diff --git a/src/modules/financial/index.ts b/src/modules/financial/index.ts index 3cb9206..ddef377 100644 --- a/src/modules/financial/index.ts +++ b/src/modules/financial/index.ts @@ -4,5 +4,6 @@ export * from './journal-entries.service.js'; export * from './invoices.service.js'; export * from './payments.service.js'; export * from './taxes.service.js'; +export * from './gl-posting.service.js'; export * from './financial.controller.js'; export { default as financialRoutes } from './financial.routes.js'; diff --git a/src/modules/financial/invoices.service.ts b/src/modules/financial/invoices.service.ts index cace96a..f1f2351 100644 --- a/src/modules/financial/invoices.service.ts +++ b/src/modules/financial/invoices.service.ts @@ -1,6 +1,9 @@ import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; import { taxesService } from './taxes.service.js'; +import { glPostingService, InvoiceForPosting } from './gl-posting.service.js'; +import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; +import { logger } from '../../shared/utils/logger.js'; export interface InvoiceLine { id: string; @@ -409,10 +412,24 @@ class InvoicesService { values.push(dto.account_id); } - // Recalculate amounts - const amountUntaxed = quantity * priceUnit; - const amountTax = 0; // TODO: Calculate taxes - const amountTotal = amountUntaxed + amountTax; + // Recalculate amounts using taxesService + const taxIds = dto.tax_ids ?? existingLine.tax_ids ?? []; + const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase'; + + const taxResult = await taxesService.calculateTaxes( + { + quantity, + priceUnit, + discount: 0, + taxIds, + }, + tenantId, + transactionType + ); + + const amountUntaxed = taxResult.amountUntaxed; + const amountTax = taxResult.amountTax; + const amountTotal = taxResult.amountTotal; updateFields.push(`amount_untaxed = $${paramIndex++}`); values.push(amountUntaxed); @@ -468,29 +485,96 @@ class InvoicesService { throw new ValidationError('La factura debe tener al menos una línea'); } - // Generate invoice number - const prefix = invoice.invoice_type === 'customer' ? 'INV' : 'BILL'; - const seqResult = await queryOne<{ next_num: number }>( - `SELECT COALESCE(MAX(CAST(SUBSTRING(number FROM 5) AS INTEGER)), 0) + 1 as next_num - FROM financial.invoices WHERE tenant_id = $1 AND number LIKE '${prefix}-%'`, - [tenantId] - ); - const invoiceNumber = `${prefix}-${String(seqResult?.next_num || 1).padStart(6, '0')}`; + logger.info('Validating invoice', { invoiceId: id, invoiceType: invoice.invoice_type }); - await query( - `UPDATE financial.invoices SET - number = $1, - status = 'open', - amount_residual = amount_total, - validated_at = CURRENT_TIMESTAMP, - validated_by = $2, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $3 AND tenant_id = $4`, - [invoiceNumber, userId, id, tenantId] - ); + // Generate invoice number using sequences service + const sequenceCode = invoice.invoice_type === 'customer' + ? SEQUENCE_CODES.INVOICE_CUSTOMER + : SEQUENCE_CODES.INVOICE_SUPPLIER; + const invoiceNumber = await sequencesService.getNextNumber(sequenceCode, tenantId); - return this.findById(id, tenantId); + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Update invoice status and number + await client.query( + `UPDATE financial.invoices SET + number = $1, + status = 'open', + amount_residual = amount_total, + validated_at = CURRENT_TIMESTAMP, + validated_by = $2, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [invoiceNumber, userId, id, tenantId] + ); + + await client.query('COMMIT'); + + // Get updated invoice with number for GL posting + const validatedInvoice = await this.findById(id, tenantId); + + // Create journal entry for the invoice (GL posting) + try { + const invoiceForPosting: InvoiceForPosting = { + id: validatedInvoice.id, + tenant_id: validatedInvoice.tenant_id, + company_id: validatedInvoice.company_id, + partner_id: validatedInvoice.partner_id, + partner_name: validatedInvoice.partner_name, + invoice_type: validatedInvoice.invoice_type, + number: validatedInvoice.number!, + invoice_date: validatedInvoice.invoice_date, + amount_untaxed: Number(validatedInvoice.amount_untaxed), + amount_tax: Number(validatedInvoice.amount_tax), + amount_total: Number(validatedInvoice.amount_total), + journal_id: validatedInvoice.journal_id, + lines: (validatedInvoice.lines || []).map(line => ({ + id: line.id, + product_id: line.product_id, + description: line.description, + quantity: Number(line.quantity), + price_unit: Number(line.price_unit), + amount_untaxed: Number(line.amount_untaxed), + amount_tax: Number(line.amount_tax), + amount_total: Number(line.amount_total), + account_id: line.account_id, + tax_ids: line.tax_ids || [], + })), + }; + + const postingResult = await glPostingService.createInvoicePosting(invoiceForPosting, userId); + + // Link journal entry to invoice + await query( + `UPDATE financial.invoices SET journal_entry_id = $1 WHERE id = $2`, + [postingResult.journal_entry_id, id] + ); + + logger.info('Invoice validated with GL posting', { + invoiceId: id, + invoiceNumber, + journalEntryId: postingResult.journal_entry_id, + journalEntryName: postingResult.journal_entry_name, + }); + } catch (postingError) { + // Log error but don't fail the validation - GL posting can be done manually + logger.error('Failed to create automatic GL posting', { + invoiceId: id, + error: (postingError as Error).message, + }); + // The invoice is still valid, just without automatic GL entry + } + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } } async cancel(id: string, tenantId: string, userId: string): Promise { @@ -508,6 +592,31 @@ class InvoicesService { throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados'); } + logger.info('Cancelling invoice', { invoiceId: id, invoiceNumber: invoice.number }); + + // Reverse journal entry if exists + if (invoice.journal_entry_id) { + try { + await glPostingService.reversePosting( + invoice.journal_entry_id, + tenantId, + `Cancelación de factura ${invoice.number}`, + userId + ); + logger.info('Journal entry reversed for cancelled invoice', { + invoiceId: id, + journalEntryId: invoice.journal_entry_id, + }); + } catch (error) { + logger.error('Failed to reverse journal entry', { + invoiceId: id, + journalEntryId: invoice.journal_entry_id, + error: (error as Error).message, + }); + // Continue with cancellation even if reversal fails + } + } + await query( `UPDATE financial.invoices SET status = 'cancelled', diff --git a/src/modules/inventory/adjustments.service.ts b/src/modules/inventory/adjustments.service.ts index d6286f7..967450f 100644 --- a/src/modules/inventory/adjustments.service.ts +++ b/src/modules/inventory/adjustments.service.ts @@ -1,5 +1,7 @@ import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; +import { valuationService } from './valuation.service.js'; +import { logger } from '../../shared/utils/logger.js'; export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled'; @@ -414,6 +416,12 @@ class AdjustmentsService { throw new ValidationError('Solo se pueden validar ajustes confirmados'); } + logger.info('Validating inventory adjustment', { + adjustmentId: id, + adjustmentName: adjustment.name, + linesCount: adjustment.lines?.length || 0, + }); + const client = await getClient(); try { @@ -461,14 +469,88 @@ class AdjustmentsService { [tenantId, line.product_id, line.location_id, line.lot_id, line.counted_qty] ); } + + // TASK-006-06: Create/consume valuation layers for adjustments + // Get product valuation info + const productInfo = await client.query( + `SELECT valuation_method, cost_price FROM inventory.products WHERE id = $1`, + [line.product_id] + ); + const product = productInfo.rows[0]; + + if (product && product.valuation_method !== 'standard') { + try { + if (difference > 0) { + // Positive adjustment = Create valuation layer (like receiving stock) + await valuationService.createLayer( + { + product_id: line.product_id, + company_id: adjustment.company_id, + quantity: difference, + unit_cost: Number(product.cost_price) || 0, + description: `Ajuste inventario positivo - ${adjustment.name}`, + }, + tenantId, + userId, + client + ); + logger.debug('Valuation layer created for positive adjustment', { + adjustmentId: id, + productId: line.product_id, + quantity: difference, + }); + } else { + // Negative adjustment = Consume valuation layers (FIFO) + const consumeResult = await valuationService.consumeFifo( + line.product_id, + adjustment.company_id, + Math.abs(difference), + tenantId, + userId, + client + ); + logger.debug('Valuation layers consumed for negative adjustment', { + adjustmentId: id, + productId: line.product_id, + quantity: Math.abs(difference), + totalCost: consumeResult.total_cost, + }); + } + + // Update average cost if using that method + if (product.valuation_method === 'average') { + await valuationService.updateProductAverageCost( + line.product_id, + adjustment.company_id, + tenantId, + client + ); + } + } catch (valErr) { + logger.warn('Failed to process valuation for adjustment', { + adjustmentId: id, + productId: line.product_id, + error: (valErr as Error).message, + }); + } + } } } await client.query('COMMIT'); + logger.info('Inventory adjustment validated', { + adjustmentId: id, + adjustmentName: adjustment.name, + }); + return this.findById(id, tenantId); } catch (error) { await client.query('ROLLBACK'); + logger.error('Error validating inventory adjustment', { + adjustmentId: id, + error: (error as Error).message, + }); throw error; } finally { client.release(); diff --git a/src/modules/inventory/pickings.service.ts b/src/modules/inventory/pickings.service.ts index 6c66c18..27d6678 100644 --- a/src/modules/inventory/pickings.service.ts +++ b/src/modules/inventory/pickings.service.ts @@ -1,5 +1,8 @@ import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; +import { stockReservationService, ReservationLine } from './stock-reservation.service.js'; +import { valuationService } from './valuation.service.js'; +import { logger } from '../../shared/utils/logger.js'; export type PickingType = 'incoming' | 'outgoing' | 'internal'; export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled'; @@ -264,31 +267,64 @@ class PickingsService { throw new ConflictError('No se puede validar un picking cancelado'); } + // TASK-006-05: Validate lots for tracked products + if (picking.moves && picking.moves.length > 0) { + for (const move of picking.moves) { + // Check if product requires lot tracking + const productResult = await queryOne<{ tracking: string; name: string }>( + `SELECT tracking, name FROM inventory.products WHERE id = $1`, + [move.product_id] + ); + + if (productResult && productResult.tracking !== 'none' && !move.lot_id) { + throw new ValidationError( + `El producto "${productResult.name || move.product_name}" requiere número de lote/serie para ser movido` + ); + } + } + } + const client = await getClient(); try { await client.query('BEGIN'); + // Release reserved stock before moving (for outgoing pickings) + if (picking.picking_type === 'outgoing' && picking.moves) { + const releaseLines: ReservationLine[] = picking.moves.map(move => ({ + productId: move.product_id, + locationId: move.location_id, + quantity: move.product_qty, + lotId: move.lot_id, + })); + + await stockReservationService.releaseWithClient( + client, + releaseLines, + tenantId + ); + } + // Update stock quants for each move for (const move of picking.moves || []) { const qty = move.product_qty; // Decrease from source location await client.query( - `INSERT INTO inventory.stock_quants (product_id, location_id, quantity) - VALUES ($1, $2, -$3) + `INSERT INTO inventory.stock_quants (product_id, location_id, quantity, tenant_id) + VALUES ($1, $2, -$3, $4) ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000')) DO UPDATE SET quantity = stock_quants.quantity - $3, updated_at = CURRENT_TIMESTAMP`, - [move.product_id, move.location_id, qty] + [move.product_id, move.location_id, qty, tenantId] ); // Increase in destination location await client.query( - `INSERT INTO inventory.stock_quants (product_id, location_id, quantity) - VALUES ($1, $2, $3) + `INSERT INTO inventory.stock_quants (product_id, location_id, quantity, tenant_id) + VALUES ($1, $2, $3, $4) ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000')) DO UPDATE SET quantity = stock_quants.quantity + $3, updated_at = CURRENT_TIMESTAMP`, - [move.product_id, move.location_dest_id, qty] + [move.product_id, move.location_dest_id, qty, tenantId] ); // Update move @@ -298,6 +334,92 @@ class PickingsService { WHERE id = $3`, [qty, userId, move.id] ); + + // TASK-006-01/02: Process stock valuation for the move + // Get location types to determine if it's incoming or outgoing + const [srcLoc, destLoc] = await Promise.all([ + client.query('SELECT location_type FROM inventory.locations WHERE id = $1', [move.location_id]), + client.query('SELECT location_type FROM inventory.locations WHERE id = $1', [move.location_dest_id]), + ]); + + const srcIsInternal = srcLoc.rows[0]?.location_type === 'internal'; + const destIsInternal = destLoc.rows[0]?.location_type === 'internal'; + + // Get product cost info for valuation + const productInfo = await client.query( + `SELECT cost_price, valuation_method FROM inventory.products WHERE id = $1`, + [move.product_id] + ); + const product = productInfo.rows[0]; + + if (product && product.valuation_method !== 'standard') { + // Incoming to internal location (create valuation layer) + if (!srcIsInternal && destIsInternal) { + try { + await valuationService.createLayer( + { + product_id: move.product_id, + company_id: picking.company_id, + quantity: qty, + unit_cost: Number(product.cost_price) || 0, + stock_move_id: move.id, + description: `Recepción - ${picking.name}`, + }, + tenantId, + userId, + client + ); + logger.debug('Valuation layer created for incoming move', { + pickingId: id, + moveId: move.id, + productId: move.product_id, + quantity: qty, + }); + } catch (valErr) { + logger.warn('Failed to create valuation layer', { + moveId: move.id, + error: (valErr as Error).message, + }); + } + } + + // Outgoing from internal location (consume valuation layers with FIFO) + if (srcIsInternal && !destIsInternal) { + try { + const consumeResult = await valuationService.consumeFifo( + move.product_id, + picking.company_id, + qty, + tenantId, + userId, + client + ); + logger.debug('Valuation layers consumed for outgoing move', { + pickingId: id, + moveId: move.id, + productId: move.product_id, + quantity: qty, + totalCost: consumeResult.total_cost, + layersConsumed: consumeResult.layers_consumed.length, + }); + } catch (valErr) { + logger.warn('Failed to consume valuation layers', { + moveId: move.id, + error: (valErr as Error).message, + }); + } + } + + // Update average cost if using that method + if (product.valuation_method === 'average') { + await valuationService.updateProductAverageCost( + move.product_id, + picking.company_id, + tenantId, + client + ); + } + } } // Update picking @@ -308,11 +430,139 @@ class PickingsService { [userId, id] ); + // TASK-003-07: Update sales order delivery_status if this is a sales order picking + if (picking.origin && picking.picking_type === 'outgoing') { + // Check if this picking is from a sales order (origin starts with 'SO-') + const orderResult = await client.query( + `SELECT so.id, so.name + FROM sales.sales_orders so + WHERE so.picking_id = $1 AND so.tenant_id = $2`, + [id, tenantId] + ); + + if (orderResult.rows.length > 0) { + const orderId = orderResult.rows[0].id; + const orderName = orderResult.rows[0].name; + + // Update qty_delivered on order lines based on moves + for (const move of picking.moves || []) { + await client.query( + `UPDATE sales.sales_order_lines + SET qty_delivered = qty_delivered + $1 + WHERE order_id = $2 AND product_id = $3`, + [move.product_qty, orderId, move.product_id] + ); + } + + // Calculate new delivery_status based on delivered quantities + await client.query( + `UPDATE sales.sales_orders SET + delivery_status = CASE + WHEN (SELECT SUM(qty_delivered) FROM sales.sales_order_lines WHERE order_id = $1) >= + (SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1) + THEN 'delivered'::varchar + WHEN (SELECT SUM(qty_delivered) FROM sales.sales_order_lines WHERE order_id = $1) > 0 + THEN 'partial'::varchar + ELSE 'pending'::varchar + END, + status = CASE + WHEN (SELECT SUM(qty_delivered) FROM sales.sales_order_lines WHERE order_id = $1) >= + (SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1) + THEN 'sale'::varchar + ELSE status + END, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [orderId, userId] + ); + + logger.info('Sales order delivery status updated', { + pickingId: id, + orderId, + orderName, + }); + } + } + + // TASK-004-04: Update purchase order receipt_status if this is a purchase order picking + if (picking.origin && picking.picking_type === 'incoming') { + // Check if this picking is from a purchase order + const poResult = await client.query( + `SELECT po.id, po.name + FROM purchase.purchase_orders po + WHERE po.picking_id = $1 AND po.tenant_id = $2`, + [id, tenantId] + ); + + if (poResult.rows.length > 0) { + const poId = poResult.rows[0].id; + const poName = poResult.rows[0].name; + + // Update qty_received on order lines based on moves + for (const move of picking.moves || []) { + await client.query( + `UPDATE purchase.purchase_order_lines + SET qty_received = COALESCE(qty_received, 0) + $1 + WHERE order_id = $2 AND product_id = $3`, + [move.product_qty, poId, move.product_id] + ); + } + + // Calculate new receipt_status based on received quantities + await client.query( + `UPDATE purchase.purchase_orders SET + receipt_status = CASE + WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >= + (SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) + THEN 'received' + WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) > 0 + THEN 'partial' + ELSE 'pending' + END, + status = CASE + WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >= + (SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) + THEN 'done' + ELSE status + END, + effective_date = CASE + WHEN (SELECT COALESCE(SUM(qty_received), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >= + (SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) + THEN CURRENT_DATE + ELSE effective_date + END, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [poId, userId] + ); + + logger.info('Purchase order receipt status updated', { + pickingId: id, + purchaseOrderId: poId, + purchaseOrderName: poName, + }); + } + } + await client.query('COMMIT'); + logger.info('Picking validated', { + pickingId: id, + pickingName: picking.name, + movesCount: picking.moves?.length || 0, + tenantId, + }); + return this.findById(id, tenantId); } catch (error) { await client.query('ROLLBACK'); + logger.error('Error validating picking', { + error: (error as Error).message, + pickingId: id, + tenantId, + }); throw error; } finally { client.release(); diff --git a/src/modules/inventory/reorder-alerts.service.ts b/src/modules/inventory/reorder-alerts.service.ts new file mode 100644 index 0000000..a206669 --- /dev/null +++ b/src/modules/inventory/reorder-alerts.service.ts @@ -0,0 +1,376 @@ +import { query, queryOne } from '../../config/database.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ReorderAlert { + product_id: string; + product_code: string; + product_name: string; + warehouse_id?: string; + warehouse_name?: string; + current_quantity: number; + reserved_quantity: number; + available_quantity: number; + reorder_point: number; + reorder_quantity: number; + min_stock: number; + max_stock?: number; + shortage: number; + suggested_order_qty: number; + alert_level: 'critical' | 'warning' | 'info'; +} + +export interface StockLevelReport { + product_id: string; + product_code: string; + product_name: string; + warehouse_id: string; + warehouse_name: string; + location_id: string; + location_name: string; + quantity: number; + reserved_quantity: number; + available_quantity: number; + lot_id?: string; + lot_number?: string; + uom_name: string; + valuation: number; +} + +export interface StockSummary { + product_id: string; + product_code: string; + product_name: string; + total_quantity: number; + total_reserved: number; + total_available: number; + warehouse_count: number; + location_count: number; + total_valuation: number; +} + +export interface ReorderAlertFilters { + warehouse_id?: string; + category_id?: string; + alert_level?: 'critical' | 'warning' | 'info' | 'all'; + page?: number; + limit?: number; +} + +export interface StockLevelFilters { + product_id?: string; + warehouse_id?: string; + location_id?: string; + include_zero?: boolean; + page?: number; + limit?: number; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ReorderAlertsService { + /** + * Get all products below their reorder point + * Checks inventory.stock_quants against products.products reorder settings + */ + async getReorderAlerts( + tenantId: string, + companyId: string, + filters: ReorderAlertFilters = {} + ): Promise<{ data: ReorderAlert[]; total: number }> { + const { warehouse_id, category_id, alert_level = 'all', page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = `WHERE p.tenant_id = $1 AND p.active = true`; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (warehouse_id) { + whereClause += ` AND l.warehouse_id = $${paramIndex++}`; + params.push(warehouse_id); + } + + if (category_id) { + whereClause += ` AND p.category_id = $${paramIndex++}`; + params.push(category_id); + } + + // Count total alerts + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(DISTINCT p.id) as count + FROM products.products p + LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id + LEFT JOIN inventory.locations l ON sq.location_id = l.id + ${whereClause} + AND p.reorder_point IS NOT NULL + AND COALESCE(sq.quantity, 0) - COALESCE(sq.reserved_quantity, 0) < p.reorder_point`, + params + ); + + // Get alerts with stock details + params.push(limit, offset); + const alerts = await query( + `SELECT + p.id as product_id, + p.code as product_code, + p.name as product_name, + w.id as warehouse_id, + w.name as warehouse_name, + COALESCE(SUM(sq.quantity), 0)::numeric as current_quantity, + COALESCE(SUM(sq.reserved_quantity), 0)::numeric as reserved_quantity, + COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) as available_quantity, + p.reorder_point, + p.reorder_quantity, + p.min_stock, + p.max_stock, + p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0)) as shortage, + COALESCE(p.reorder_quantity, p.reorder_point * 2) as suggested_order_qty, + CASE + WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 'critical' + WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 'warning' + ELSE 'info' + END as alert_level + FROM products.products p + LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id + LEFT JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal' + LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id + ${whereClause} + AND p.reorder_point IS NOT NULL + GROUP BY p.id, p.code, p.name, w.id, w.name, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock + HAVING COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point + ORDER BY + CASE + WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 1 + WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 2 + ELSE 3 + END, + (p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0))) DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + // Filter by alert level if specified + const filteredAlerts = alert_level === 'all' + ? alerts + : alerts.filter(a => a.alert_level === alert_level); + + logger.info('Reorder alerts retrieved', { + tenantId, + companyId, + totalAlerts: parseInt(countResult?.count || '0', 10), + returnedAlerts: filteredAlerts.length, + }); + + return { + data: filteredAlerts, + total: parseInt(countResult?.count || '0', 10), + }; + } + + /** + * Get stock levels by product, warehouse, and location + * TASK-006-03: Vista niveles de stock + */ + async getStockLevels( + tenantId: string, + filters: StockLevelFilters = {} + ): Promise<{ data: StockLevelReport[]; total: number }> { + const { product_id, warehouse_id, location_id, include_zero = false, page = 1, limit = 100 } = filters; + const offset = (page - 1) * limit; + + let whereClause = `WHERE sq.tenant_id = $1`; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (product_id) { + whereClause += ` AND sq.product_id = $${paramIndex++}`; + params.push(product_id); + } + + if (warehouse_id) { + whereClause += ` AND l.warehouse_id = $${paramIndex++}`; + params.push(warehouse_id); + } + + if (location_id) { + whereClause += ` AND sq.location_id = $${paramIndex++}`; + params.push(location_id); + } + + if (!include_zero) { + whereClause += ` AND (sq.quantity != 0 OR sq.reserved_quantity != 0)`; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM inventory.stock_quants sq + JOIN inventory.locations l ON sq.location_id = l.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT + sq.product_id, + p.code as product_code, + p.name as product_name, + w.id as warehouse_id, + w.name as warehouse_name, + l.id as location_id, + l.name as location_name, + sq.quantity, + sq.reserved_quantity, + sq.quantity - sq.reserved_quantity as available_quantity, + sq.lot_id, + lot.name as lot_number, + uom.name as uom_name, + COALESCE(sq.quantity * p.cost_price, 0) as valuation + FROM inventory.stock_quants sq + JOIN inventory.products p ON sq.product_id = p.id + JOIN inventory.locations l ON sq.location_id = l.id + LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id + LEFT JOIN inventory.lots lot ON sq.lot_id = lot.id + LEFT JOIN core.uom uom ON p.uom_id = uom.id + ${whereClause} + ORDER BY p.name, w.name, l.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + /** + * Get stock summary grouped by product + */ + async getStockSummary( + tenantId: string, + productId?: string + ): Promise { + let whereClause = `WHERE sq.tenant_id = $1`; + const params: any[] = [tenantId]; + + if (productId) { + whereClause += ` AND sq.product_id = $2`; + params.push(productId); + } + + return query( + `SELECT + p.id as product_id, + p.code as product_code, + p.name as product_name, + SUM(sq.quantity) as total_quantity, + SUM(sq.reserved_quantity) as total_reserved, + SUM(sq.quantity - sq.reserved_quantity) as total_available, + COUNT(DISTINCT l.warehouse_id) as warehouse_count, + COUNT(DISTINCT sq.location_id) as location_count, + COALESCE(SUM(sq.quantity * p.cost_price), 0) as total_valuation + FROM inventory.stock_quants sq + JOIN inventory.products p ON sq.product_id = p.id + JOIN inventory.locations l ON sq.location_id = l.id + ${whereClause} + GROUP BY p.id, p.code, p.name + ORDER BY p.name`, + params + ); + } + + /** + * Check if a specific product needs reorder + */ + async checkProductReorder( + productId: string, + tenantId: string, + warehouseId?: string + ): Promise { + let whereClause = `WHERE p.id = $1 AND p.tenant_id = $2`; + const params: any[] = [productId, tenantId]; + + if (warehouseId) { + whereClause += ` AND l.warehouse_id = $3`; + params.push(warehouseId); + } + + const result = await queryOne( + `SELECT + p.id as product_id, + p.code as product_code, + p.name as product_name, + w.id as warehouse_id, + w.name as warehouse_name, + COALESCE(SUM(sq.quantity), 0)::numeric as current_quantity, + COALESCE(SUM(sq.reserved_quantity), 0)::numeric as reserved_quantity, + COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) as available_quantity, + p.reorder_point, + p.reorder_quantity, + p.min_stock, + p.max_stock, + GREATEST(0, p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0))) as shortage, + COALESCE(p.reorder_quantity, p.reorder_point * 2) as suggested_order_qty, + CASE + WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 'critical' + WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 'warning' + ELSE 'info' + END as alert_level + FROM products.products p + LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id + LEFT JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal' + LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id + ${whereClause} + GROUP BY p.id, p.code, p.name, w.id, w.name, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock`, + params + ); + + // Only return if below reorder point + if (result && Number(result.available_quantity) < Number(result.reorder_point)) { + return result; + } + + return null; + } + + /** + * Get products with low stock for dashboard/notifications + */ + async getLowStockProductsCount( + tenantId: string, + companyId: string + ): Promise<{ critical: number; warning: number; total: number }> { + const result = await queryOne<{ critical: string; warning: string }>( + `SELECT + COUNT(DISTINCT CASE WHEN available <= p.min_stock THEN p.id END) as critical, + COUNT(DISTINCT CASE WHEN available > p.min_stock AND available < p.reorder_point THEN p.id END) as warning + FROM products.products p + LEFT JOIN ( + SELECT product_id, SUM(quantity) - SUM(reserved_quantity) as available + FROM inventory.stock_quants sq + JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal' + WHERE sq.tenant_id = $1 + GROUP BY product_id + ) stock ON stock.product_id = p.inventory_product_id + WHERE p.tenant_id = $1 AND p.active = true AND p.reorder_point IS NOT NULL`, + [tenantId] + ); + + const critical = parseInt(result?.critical || '0', 10); + const warning = parseInt(result?.warning || '0', 10); + + return { + critical, + warning, + total: critical + warning, + }; + } +} + +export const reorderAlertsService = new ReorderAlertsService(); diff --git a/src/modules/inventory/services/index.ts b/src/modules/inventory/services/index.ts index d9ca5a2..30d2f49 100644 --- a/src/modules/inventory/services/index.ts +++ b/src/modules/inventory/services/index.ts @@ -3,3 +3,33 @@ export { StockSearchParams, MovementSearchParams, } from './inventory.service'; + +// Stock reservation service for sales orders and transfers +export { + stockReservationService, + ReservationLine, + ReservationResult, + ReservationLineResult, + StockAvailability, +} from '../stock-reservation.service.js'; + +// Valuation service for FIFO/Average costing +export { + valuationService, + ValuationMethod, + StockValuationLayer as ValuationLayer, + CreateValuationLayerDto, + ValuationSummary, + FifoConsumptionResult, + ProductCostResult, +} from '../valuation.service.js'; + +// Reorder alerts service for stock level monitoring +export { + reorderAlertsService, + ReorderAlert, + StockLevelReport, + StockSummary, + ReorderAlertFilters, + StockLevelFilters, +} from '../reorder-alerts.service.js'; diff --git a/src/modules/inventory/stock-reservation.service.ts b/src/modules/inventory/stock-reservation.service.ts new file mode 100644 index 0000000..4be2f87 --- /dev/null +++ b/src/modules/inventory/stock-reservation.service.ts @@ -0,0 +1,473 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +/** + * Stock Reservation Service + * + * Manages soft reservations for stock. Reservations don't move stock, + * they mark quantities as committed to specific orders/documents. + * + * Key concepts: + * - quantity: Total physical stock at location + * - reserved_quantity: Stock committed to orders but not yet picked + * - available = quantity - reserved_quantity + * + * Used by: + * - Sales Orders: Reserve on confirm, release on cancel + * - Transfers: Reserve on confirm, release on complete/cancel + */ + +export interface ReservationLine { + productId: string; + locationId: string; + quantity: number; + lotId?: string; +} + +export interface ReservationResult { + success: boolean; + lines: ReservationLineResult[]; + errors: string[]; +} + +export interface ReservationLineResult { + productId: string; + locationId: string; + lotId?: string; + requestedQty: number; + reservedQty: number; + availableQty: number; + success: boolean; + error?: string; +} + +export interface StockAvailability { + productId: string; + locationId: string; + lotId?: string; + quantity: number; + reservedQuantity: number; + availableQuantity: number; +} + +class StockReservationService { + /** + * Check stock availability for a list of products at locations + */ + async checkAvailability( + lines: ReservationLine[], + tenantId: string + ): Promise { + const results: StockAvailability[] = []; + + for (const line of lines) { + const lotCondition = line.lotId + ? 'AND sq.lot_id = $4' + : 'AND sq.lot_id IS NULL'; + + const params = line.lotId + ? [tenantId, line.productId, line.locationId, line.lotId] + : [tenantId, line.productId, line.locationId]; + + const quant = await queryOne<{ + quantity: string; + reserved_quantity: string; + }>( + `SELECT + COALESCE(SUM(sq.quantity), 0) as quantity, + COALESCE(SUM(sq.reserved_quantity), 0) as reserved_quantity + FROM inventory.stock_quants sq + WHERE sq.tenant_id = $1 + AND sq.product_id = $2 + AND sq.location_id = $3 + ${lotCondition}`, + params + ); + + const quantity = parseFloat(quant?.quantity || '0'); + const reservedQuantity = parseFloat(quant?.reserved_quantity || '0'); + + results.push({ + productId: line.productId, + locationId: line.locationId, + lotId: line.lotId, + quantity, + reservedQuantity, + availableQuantity: quantity - reservedQuantity, + }); + } + + return results; + } + + /** + * Reserve stock for an order/document + * + * @param lines - Lines to reserve + * @param tenantId - Tenant ID + * @param sourceDocument - Reference to source document (e.g., "SO-000001") + * @param allowPartial - If true, reserve what's available even if less than requested + * @returns Reservation result with details per line + */ + async reserve( + lines: ReservationLine[], + tenantId: string, + sourceDocument: string, + allowPartial: boolean = false + ): Promise { + const results: ReservationLineResult[] = []; + const errors: string[] = []; + + // First check availability + const availability = await this.checkAvailability(lines, tenantId); + + // Validate all lines have sufficient stock (if partial not allowed) + if (!allowPartial) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const avail = availability[i]; + + if (avail.availableQuantity < line.quantity) { + errors.push( + `Producto ${line.productId}: disponible ${avail.availableQuantity}, solicitado ${line.quantity}` + ); + } + } + + if (errors.length > 0) { + return { + success: false, + lines: [], + errors, + }; + } + } + + // Reserve stock + const client = await getClient(); + try { + await client.query('BEGIN'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const avail = availability[i]; + const qtyToReserve = allowPartial + ? Math.min(line.quantity, avail.availableQuantity) + : line.quantity; + + if (qtyToReserve <= 0) { + results.push({ + productId: line.productId, + locationId: line.locationId, + lotId: line.lotId, + requestedQty: line.quantity, + reservedQty: 0, + availableQty: avail.availableQuantity, + success: false, + error: 'Sin stock disponible', + }); + continue; + } + + // Update reserved_quantity + const lotCondition = line.lotId + ? 'AND lot_id = $5' + : 'AND lot_id IS NULL'; + + const params = line.lotId + ? [qtyToReserve, tenantId, line.productId, line.locationId, line.lotId] + : [qtyToReserve, tenantId, line.productId, line.locationId]; + + await client.query( + `UPDATE inventory.stock_quants + SET reserved_quantity = reserved_quantity + $1, + updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = $2 + AND product_id = $3 + AND location_id = $4 + ${lotCondition}`, + params + ); + + results.push({ + productId: line.productId, + locationId: line.locationId, + lotId: line.lotId, + requestedQty: line.quantity, + reservedQty: qtyToReserve, + availableQty: avail.availableQuantity - qtyToReserve, + success: true, + }); + } + + await client.query('COMMIT'); + + logger.info('Stock reserved', { + sourceDocument, + tenantId, + linesReserved: results.filter(r => r.success).length, + }); + + return { + success: results.every(r => r.success), + lines: results, + errors, + }; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error reserving stock', { + error: (error as Error).message, + sourceDocument, + tenantId, + }); + throw error; + } finally { + client.release(); + } + } + + /** + * Release previously reserved stock + * + * @param lines - Lines to release + * @param tenantId - Tenant ID + * @param sourceDocument - Reference to source document + */ + async release( + lines: ReservationLine[], + tenantId: string, + sourceDocument: string + ): Promise { + const client = await getClient(); + try { + await client.query('BEGIN'); + + for (const line of lines) { + const lotCondition = line.lotId + ? 'AND lot_id = $5' + : 'AND lot_id IS NULL'; + + const params = line.lotId + ? [line.quantity, tenantId, line.productId, line.locationId, line.lotId] + : [line.quantity, tenantId, line.productId, line.locationId]; + + // Decrease reserved_quantity (don't go below 0) + await client.query( + `UPDATE inventory.stock_quants + SET reserved_quantity = GREATEST(reserved_quantity - $1, 0), + updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = $2 + AND product_id = $3 + AND location_id = $4 + ${lotCondition}`, + params + ); + } + + await client.query('COMMIT'); + + logger.info('Stock reservation released', { + sourceDocument, + tenantId, + linesReleased: lines.length, + }); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error releasing stock reservation', { + error: (error as Error).message, + sourceDocument, + tenantId, + }); + throw error; + } finally { + client.release(); + } + } + + /** + * Reserve stock within an existing transaction + * Used when reservation is part of a larger transaction (e.g., confirm order) + */ + async reserveWithClient( + client: PoolClient, + lines: ReservationLine[], + tenantId: string, + sourceDocument: string, + allowPartial: boolean = false + ): Promise { + const results: ReservationLineResult[] = []; + const errors: string[] = []; + + // Check availability + for (const line of lines) { + const lotCondition = line.lotId + ? 'AND sq.lot_id = $4' + : 'AND sq.lot_id IS NULL'; + + const params = line.lotId + ? [tenantId, line.productId, line.locationId, line.lotId] + : [tenantId, line.productId, line.locationId]; + + const quantResult = await client.query( + `SELECT + COALESCE(SUM(sq.quantity), 0) as quantity, + COALESCE(SUM(sq.reserved_quantity), 0) as reserved_quantity + FROM inventory.stock_quants sq + WHERE sq.tenant_id = $1 + AND sq.product_id = $2 + AND sq.location_id = $3 + ${lotCondition}`, + params + ); + + const quantity = parseFloat(quantResult.rows[0]?.quantity || '0'); + const reservedQuantity = parseFloat(quantResult.rows[0]?.reserved_quantity || '0'); + const availableQuantity = quantity - reservedQuantity; + const qtyToReserve = allowPartial + ? Math.min(line.quantity, availableQuantity) + : line.quantity; + + if (!allowPartial && availableQuantity < line.quantity) { + errors.push( + `Producto ${line.productId}: disponible ${availableQuantity}, solicitado ${line.quantity}` + ); + results.push({ + productId: line.productId, + locationId: line.locationId, + lotId: line.lotId, + requestedQty: line.quantity, + reservedQty: 0, + availableQty: availableQuantity, + success: false, + error: 'Stock insuficiente', + }); + continue; + } + + if (qtyToReserve > 0) { + // Update reserved_quantity + const updateLotCondition = line.lotId + ? 'AND lot_id = $5' + : 'AND lot_id IS NULL'; + + const updateParams = line.lotId + ? [qtyToReserve, tenantId, line.productId, line.locationId, line.lotId] + : [qtyToReserve, tenantId, line.productId, line.locationId]; + + await client.query( + `UPDATE inventory.stock_quants + SET reserved_quantity = reserved_quantity + $1, + updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = $2 + AND product_id = $3 + AND location_id = $4 + ${updateLotCondition}`, + updateParams + ); + } + + results.push({ + productId: line.productId, + locationId: line.locationId, + lotId: line.lotId, + requestedQty: line.quantity, + reservedQty: qtyToReserve, + availableQty: availableQuantity - qtyToReserve, + success: qtyToReserve > 0 || line.quantity === 0, + }); + } + + return { + success: errors.length === 0, + lines: results, + errors, + }; + } + + /** + * Release stock within an existing transaction + */ + async releaseWithClient( + client: PoolClient, + lines: ReservationLine[], + tenantId: string + ): Promise { + for (const line of lines) { + const lotCondition = line.lotId + ? 'AND lot_id = $5' + : 'AND lot_id IS NULL'; + + const params = line.lotId + ? [line.quantity, tenantId, line.productId, line.locationId, line.lotId] + : [line.quantity, tenantId, line.productId, line.locationId]; + + await client.query( + `UPDATE inventory.stock_quants + SET reserved_quantity = GREATEST(reserved_quantity - $1, 0), + updated_at = CURRENT_TIMESTAMP + WHERE tenant_id = $2 + AND product_id = $3 + AND location_id = $4 + ${lotCondition}`, + params + ); + } + } + + /** + * Get total available stock for a product across all locations + */ + async getProductAvailability( + productId: string, + tenantId: string, + warehouseId?: string + ): Promise<{ + totalQuantity: number; + totalReserved: number; + totalAvailable: number; + byLocation: StockAvailability[]; + }> { + let whereClause = 'WHERE sq.tenant_id = $1 AND sq.product_id = $2'; + const params: any[] = [tenantId, productId]; + + if (warehouseId) { + whereClause += ' AND l.warehouse_id = $3'; + params.push(warehouseId); + } + + const result = await query<{ + location_id: string; + lot_id: string | null; + quantity: string; + reserved_quantity: string; + }>( + `SELECT sq.location_id, sq.lot_id, sq.quantity, sq.reserved_quantity + FROM inventory.stock_quants sq + LEFT JOIN inventory.locations l ON sq.location_id = l.id + ${whereClause}`, + params + ); + + const byLocation: StockAvailability[] = result.map(row => ({ + productId, + locationId: row.location_id, + lotId: row.lot_id || undefined, + quantity: parseFloat(row.quantity), + reservedQuantity: parseFloat(row.reserved_quantity), + availableQuantity: parseFloat(row.quantity) - parseFloat(row.reserved_quantity), + })); + + const totalQuantity = byLocation.reduce((sum, l) => sum + l.quantity, 0); + const totalReserved = byLocation.reduce((sum, l) => sum + l.reservedQuantity, 0); + + return { + totalQuantity, + totalReserved, + totalAvailable: totalQuantity - totalReserved, + byLocation, + }; + } +} + +export const stockReservationService = new StockReservationService(); diff --git a/src/modules/projects/billing.service.ts b/src/modules/projects/billing.service.ts new file mode 100644 index 0000000..855f016 --- /dev/null +++ b/src/modules/projects/billing.service.ts @@ -0,0 +1,785 @@ +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 interface BillingRate { + id: string; + tenant_id: string; + company_id: string; + project_id?: string; + user_id?: string; + rate_type: 'project' | 'user' | 'project_user'; + hourly_rate: number; + currency_id: string; + currency_code?: string; + effective_from?: Date; + effective_to?: Date; + active: boolean; + created_at: Date; +} + +export interface CreateBillingRateDto { + company_id: string; + project_id?: string; + user_id?: string; + hourly_rate: number; + currency_id: string; + effective_from?: string; + effective_to?: string; +} + +export interface UpdateBillingRateDto { + hourly_rate?: number; + currency_id?: string; + effective_from?: string | null; + effective_to?: string | null; + active?: boolean; +} + +export interface UnbilledTimesheet { + id: string; + project_id: string; + project_name: string; + task_id?: string; + task_name?: string; + user_id: string; + user_name: string; + date: Date; + hours: number; + description?: string; + hourly_rate: number; + billable_amount: number; + currency_id: string; + currency_code?: string; +} + +export interface TimesheetBillingSummary { + project_id: string; + project_name: string; + partner_id?: string; + partner_name?: string; + total_hours: number; + total_amount: number; + currency_id: string; + currency_code?: string; + timesheet_count: number; + date_range: { + from: Date; + to: Date; + }; +} + +export interface InvoiceFromTimesheetsResult { + invoice_id: string; + invoice_number?: string; + timesheets_billed: number; + total_hours: number; + total_amount: number; +} + +export interface BillingFilters { + project_id?: string; + user_id?: string; + partner_id?: string; + date_from?: string; + date_to?: string; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class BillingService { + // -------------------------------------------------------------------------- + // BILLING RATES MANAGEMENT + // -------------------------------------------------------------------------- + + /** + * Get billing rate for a project/user combination + * Priority: project_user > project > user > company default + */ + async getBillingRate( + tenantId: string, + companyId: string, + projectId?: string, + userId?: string, + date?: Date + ): Promise { + const targetDate = date || new Date(); + + // Try to find the most specific rate + const rates = await query( + `SELECT br.*, c.code as currency_code + FROM projects.billing_rates br + LEFT JOIN core.currencies c ON br.currency_id = c.id + WHERE br.tenant_id = $1 + AND br.company_id = $2 + AND br.active = true + AND (br.effective_from IS NULL OR br.effective_from <= $3) + AND (br.effective_to IS NULL OR br.effective_to >= $3) + AND ( + (br.project_id = $4 AND br.user_id = $5) OR + (br.project_id = $4 AND br.user_id IS NULL) OR + (br.project_id IS NULL AND br.user_id = $5) OR + (br.project_id IS NULL AND br.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br.project_id IS NOT NULL AND br.user_id IS NOT NULL THEN 1 + WHEN br.project_id IS NOT NULL THEN 2 + WHEN br.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1`, + [tenantId, companyId, targetDate, projectId, userId] + ); + + return rates.length > 0 ? rates[0] : null; + } + + /** + * Create a new billing rate + */ + async createBillingRate( + dto: CreateBillingRateDto, + tenantId: string, + userId: string + ): Promise { + if (dto.hourly_rate < 0) { + throw new ValidationError('La tarifa por hora no puede ser negativa'); + } + + // Determine rate type + let rateType: 'project' | 'user' | 'project_user' = 'project'; + if (dto.project_id && dto.user_id) { + rateType = 'project_user'; + } else if (dto.user_id) { + rateType = 'user'; + } + + const rate = await queryOne( + `INSERT INTO projects.billing_rates ( + tenant_id, company_id, project_id, user_id, rate_type, + hourly_rate, currency_id, effective_from, effective_to, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, dto.company_id, dto.project_id, dto.user_id, rateType, + dto.hourly_rate, dto.currency_id, dto.effective_from, dto.effective_to, userId + ] + ); + + return rate!; + } + + /** + * Update a billing rate + */ + async updateBillingRate( + id: string, + dto: UpdateBillingRateDto, + tenantId: string, + userId: string + ): Promise { + const existing = await queryOne( + `SELECT * FROM projects.billing_rates WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!existing) { + throw new NotFoundError('Tarifa de facturación no encontrada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.hourly_rate !== undefined) { + if (dto.hourly_rate < 0) { + throw new ValidationError('La tarifa por hora no puede ser negativa'); + } + updateFields.push(`hourly_rate = $${paramIndex++}`); + values.push(dto.hourly_rate); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.effective_from !== undefined) { + updateFields.push(`effective_from = $${paramIndex++}`); + values.push(dto.effective_from); + } + if (dto.effective_to !== undefined) { + updateFields.push(`effective_to = $${paramIndex++}`); + values.push(dto.effective_to); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE projects.billing_rates SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + const updated = await queryOne( + `SELECT br.*, c.code as currency_code + FROM projects.billing_rates br + LEFT JOIN core.currencies c ON br.currency_id = c.id + WHERE br.id = $1`, + [id] + ); + + return updated!; + } + + /** + * Get all billing rates for a company + */ + async getBillingRates( + tenantId: string, + companyId: string, + projectId?: string + ): Promise { + let whereClause = 'WHERE br.tenant_id = $1 AND br.company_id = $2'; + const params: any[] = [tenantId, companyId]; + + if (projectId) { + whereClause += ' AND (br.project_id = $3 OR br.project_id IS NULL)'; + params.push(projectId); + } + + return query( + `SELECT br.*, c.code as currency_code, + p.name as project_name, u.name as user_name + FROM projects.billing_rates br + LEFT JOIN core.currencies c ON br.currency_id = c.id + LEFT JOIN projects.projects p ON br.project_id = p.id + LEFT JOIN auth.users u ON br.user_id = u.id + ${whereClause} + ORDER BY br.project_id NULLS LAST, br.user_id NULLS LAST, br.effective_from DESC`, + params + ); + } + + // -------------------------------------------------------------------------- + // UNBILLED TIMESHEETS + // -------------------------------------------------------------------------- + + /** + * Get unbilled approved timesheets with calculated billable amounts + */ + async getUnbilledTimesheets( + tenantId: string, + companyId: string, + filters: BillingFilters = {} + ): Promise<{ data: UnbilledTimesheet[]; total: number }> { + const { project_id, user_id, partner_id, date_from, date_to } = filters; + + let whereClause = `WHERE ts.tenant_id = $1 + AND ts.company_id = $2 + AND ts.status = 'approved' + AND ts.billable = true + AND ts.invoice_line_id IS NULL`; + const params: any[] = [tenantId, companyId]; + let paramIndex = 3; + + if (project_id) { + whereClause += ` AND ts.project_id = $${paramIndex++}`; + params.push(project_id); + } + + if (user_id) { + whereClause += ` AND ts.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (partner_id) { + whereClause += ` AND p.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (date_from) { + whereClause += ` AND ts.date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND ts.date <= $${paramIndex++}`; + params.push(date_to); + } + + // Get count + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM projects.timesheets ts + JOIN projects.projects p ON ts.project_id = p.id + ${whereClause}`, + params + ); + + // Get timesheets with billing rates + const timesheets = await query( + `SELECT ts.id, ts.project_id, p.name as project_name, + ts.task_id, t.name as task_name, + ts.user_id, u.name as user_name, + ts.date, ts.hours, ts.description, + COALESCE(br.hourly_rate, 0) as hourly_rate, + COALESCE(br.hourly_rate * ts.hours, 0) as billable_amount, + COALESCE(br.currency_id, c.id) as currency_id, + COALESCE(cur.code, 'MXN') as currency_code + FROM projects.timesheets ts + JOIN projects.projects p ON ts.project_id = p.id + LEFT JOIN projects.tasks t ON ts.task_id = t.id + JOIN auth.users u ON ts.user_id = u.id + LEFT JOIN auth.companies c ON ts.company_id = c.id + LEFT JOIN LATERAL ( + SELECT hourly_rate, currency_id + FROM projects.billing_rates br2 + WHERE br2.tenant_id = ts.tenant_id + AND br2.company_id = ts.company_id + AND br2.active = true + AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) + AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) + AND ( + (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR + (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR + (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR + (br2.project_id IS NULL AND br2.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 + WHEN br2.project_id IS NOT NULL THEN 2 + WHEN br2.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1 + ) br ON true + LEFT JOIN core.currencies cur ON br.currency_id = cur.id + ${whereClause} + ORDER BY ts.date DESC, ts.created_at DESC`, + params + ); + + return { + data: timesheets, + total: parseInt(countResult?.count || '0', 10), + }; + } + + /** + * Get billing summary by project + */ + async getBillingSummary( + tenantId: string, + companyId: string, + filters: BillingFilters = {} + ): Promise { + const { partner_id, date_from, date_to } = filters; + + let whereClause = `WHERE ts.tenant_id = $1 + AND ts.company_id = $2 + AND ts.status = 'approved' + AND ts.billable = true + AND ts.invoice_line_id IS NULL`; + const params: any[] = [tenantId, companyId]; + let paramIndex = 3; + + if (partner_id) { + whereClause += ` AND p.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (date_from) { + whereClause += ` AND ts.date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND ts.date <= $${paramIndex++}`; + params.push(date_to); + } + + return query( + `SELECT p.id as project_id, p.name as project_name, + p.partner_id, pr.name as partner_name, + SUM(ts.hours) as total_hours, + SUM(COALESCE(br.hourly_rate * ts.hours, 0)) as total_amount, + COALESCE(MIN(br.currency_id), (SELECT id FROM core.currencies WHERE code = 'MXN' LIMIT 1)) as currency_id, + COALESCE(MIN(cur.code), 'MXN') as currency_code, + COUNT(ts.id) as timesheet_count, + MIN(ts.date) as date_from, + MAX(ts.date) as date_to + FROM projects.timesheets ts + JOIN projects.projects p ON ts.project_id = p.id + LEFT JOIN core.partners pr ON p.partner_id = pr.id + LEFT JOIN LATERAL ( + SELECT hourly_rate, currency_id + FROM projects.billing_rates br2 + WHERE br2.tenant_id = ts.tenant_id + AND br2.company_id = ts.company_id + AND br2.active = true + AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) + AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) + AND ( + (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR + (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR + (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR + (br2.project_id IS NULL AND br2.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 + WHEN br2.project_id IS NOT NULL THEN 2 + WHEN br2.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1 + ) br ON true + LEFT JOIN core.currencies cur ON br.currency_id = cur.id + ${whereClause} + GROUP BY p.id, p.name, p.partner_id, pr.name + ORDER BY total_amount DESC`, + params + ); + } + + // -------------------------------------------------------------------------- + // CREATE INVOICE FROM TIMESHEETS + // -------------------------------------------------------------------------- + + /** + * Create an invoice from unbilled timesheets + */ + async createInvoiceFromTimesheets( + tenantId: string, + companyId: string, + partnerId: string, + timesheetIds: string[], + userId: string, + options: { + currency_id?: string; + group_by?: 'project' | 'user' | 'task' | 'none'; + notes?: string; + } = {} + ): Promise { + const { currency_id, group_by = 'project', notes } = options; + + if (!timesheetIds.length) { + throw new ValidationError('Debe seleccionar al menos un timesheet para facturar'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Verify all timesheets exist, are approved, billable, and not yet invoiced + const timesheets = await query<{ + id: string; + project_id: string; + project_name: string; + task_id: string; + task_name: string; + user_id: string; + user_name: string; + date: Date; + hours: number; + description: string; + hourly_rate: number; + billable_amount: number; + currency_id: string; + }>( + `SELECT ts.id, ts.project_id, p.name as project_name, + ts.task_id, t.name as task_name, + ts.user_id, u.name as user_name, + ts.date, ts.hours, ts.description, + COALESCE(br.hourly_rate, 0) as hourly_rate, + COALESCE(br.hourly_rate * ts.hours, 0) as billable_amount, + COALESCE(br.currency_id, (SELECT id FROM core.currencies WHERE code = 'MXN' LIMIT 1)) as currency_id + FROM projects.timesheets ts + JOIN projects.projects p ON ts.project_id = p.id + LEFT JOIN projects.tasks t ON ts.task_id = t.id + JOIN auth.users u ON ts.user_id = u.id + LEFT JOIN LATERAL ( + SELECT hourly_rate, currency_id + FROM projects.billing_rates br2 + WHERE br2.tenant_id = ts.tenant_id + AND br2.company_id = ts.company_id + AND br2.active = true + AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) + AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) + AND ( + (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR + (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR + (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR + (br2.project_id IS NULL AND br2.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 + WHEN br2.project_id IS NOT NULL THEN 2 + WHEN br2.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1 + ) br ON true + WHERE ts.id = ANY($1) + AND ts.tenant_id = $2 + AND ts.company_id = $3 + AND ts.status = 'approved' + AND ts.billable = true + AND ts.invoice_line_id IS NULL`, + [timesheetIds, tenantId, companyId] + ); + + if (timesheets.length !== timesheetIds.length) { + throw new ValidationError( + `Algunos timesheets no son válidos para facturación. ` + + `Esperados: ${timesheetIds.length}, Encontrados: ${timesheets.length}. ` + + `Verifique que estén aprobados, sean facturables y no estén ya facturados.` + ); + } + + // Determine currency + const invoiceCurrency = currency_id || timesheets[0]?.currency_id; + + // Calculate totals + const totalHours = timesheets.reduce((sum, ts) => sum + ts.hours, 0); + const totalAmount = timesheets.reduce((sum, ts) => sum + ts.billable_amount, 0); + + // Create invoice + const invoiceResult = await client.query<{ id: string }>( + `INSERT INTO financial.invoices ( + tenant_id, company_id, partner_id, invoice_type, invoice_date, + currency_id, amount_untaxed, amount_tax, amount_total, + amount_paid, amount_residual, notes, created_by + ) + VALUES ($1, $2, $3, 'customer', CURRENT_DATE, $4, $5, 0, $5, 0, $5, $6, $7) + RETURNING id`, + [tenantId, companyId, partnerId, invoiceCurrency, totalAmount, notes, userId] + ); + + const invoiceId = invoiceResult.rows[0].id; + + // Group timesheets for invoice lines + let lineData: { description: string; hours: number; rate: number; amount: number; timesheetIds: string[] }[]; + + if (group_by === 'none') { + // One line per timesheet + lineData = timesheets.map(ts => ({ + description: `${ts.project_name}${ts.task_name ? ' - ' + ts.task_name : ''}: ${ts.description || 'Horas de trabajo'} (${ts.date})`, + hours: ts.hours, + rate: ts.hourly_rate, + amount: ts.billable_amount, + timesheetIds: [ts.id], + })); + } else if (group_by === 'project') { + // Group by project + const byProject = new Map(); + for (const ts of timesheets) { + const existing = byProject.get(ts.project_id); + if (existing) { + existing.hours += ts.hours; + existing.amount += ts.billable_amount; + existing.timesheetIds.push(ts.id); + } else { + byProject.set(ts.project_id, { + description: `Horas de consultoría - ${ts.project_name}`, + hours: ts.hours, + rate: ts.hourly_rate, + amount: ts.billable_amount, + timesheetIds: [ts.id], + }); + } + } + lineData = Array.from(byProject.values()); + } else if (group_by === 'user') { + // Group by user + const byUser = new Map(); + for (const ts of timesheets) { + const existing = byUser.get(ts.user_id); + if (existing) { + existing.hours += ts.hours; + existing.amount += ts.billable_amount; + existing.timesheetIds.push(ts.id); + } else { + byUser.set(ts.user_id, { + description: `Horas de consultoría - ${ts.user_name}`, + hours: ts.hours, + rate: ts.hourly_rate, + amount: ts.billable_amount, + timesheetIds: [ts.id], + }); + } + } + lineData = Array.from(byUser.values()); + } else { + // Group by task + const byTask = new Map(); + for (const ts of timesheets) { + const key = ts.task_id || 'no-task'; + const existing = byTask.get(key); + if (existing) { + existing.hours += ts.hours; + existing.amount += ts.billable_amount; + existing.timesheetIds.push(ts.id); + } else { + byTask.set(key, { + description: ts.task_name ? `Tarea: ${ts.task_name}` : `Proyecto: ${ts.project_name}`, + hours: ts.hours, + rate: ts.hourly_rate, + amount: ts.billable_amount, + timesheetIds: [ts.id], + }); + } + } + lineData = Array.from(byTask.values()); + } + + // Create invoice lines and link timesheets + for (const line of lineData) { + // Create invoice line + const lineResult = await client.query<{ id: string }>( + `INSERT INTO financial.invoice_lines ( + invoice_id, description, quantity, price_unit, + amount_untaxed, amount_tax, amount_total + ) + VALUES ($1, $2, $3, $4, $5, 0, $5) + RETURNING id`, + [invoiceId, `${line.description} (${line.hours} hrs)`, line.hours, line.rate, line.amount] + ); + + const lineId = lineResult.rows[0].id; + + // Link timesheets to this invoice line + await client.query( + `UPDATE projects.timesheets + SET invoice_line_id = $1, updated_by = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = ANY($3)`, + [lineId, userId, line.timesheetIds] + ); + } + + await client.query('COMMIT'); + + logger.info('Invoice created from timesheets', { + invoice_id: invoiceId, + timesheet_count: timesheets.length, + total_hours: totalHours, + total_amount: totalAmount, + }); + + return { + invoice_id: invoiceId, + timesheets_billed: timesheets.length, + total_hours: totalHours, + total_amount: totalAmount, + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Get billing history for a project + */ + async getProjectBillingHistory( + tenantId: string, + projectId: string + ): Promise<{ + total_hours_billed: number; + total_amount_billed: number; + unbilled_hours: number; + unbilled_amount: number; + invoices: { id: string; number: string; date: Date; amount: number; status: string }[]; + }> { + // Get billed totals + const billedStats = await queryOne<{ hours: string; amount: string }>( + `SELECT COALESCE(SUM(ts.hours), 0) as hours, + COALESCE(SUM(il.amount_total), 0) as amount + FROM projects.timesheets ts + JOIN financial.invoice_lines il ON ts.invoice_line_id = il.id + WHERE ts.tenant_id = $1 AND ts.project_id = $2 AND ts.invoice_line_id IS NOT NULL`, + [tenantId, projectId] + ); + + // Get unbilled totals + const unbilledStats = await queryOne<{ hours: string; amount: string }>( + `SELECT COALESCE(SUM(ts.hours), 0) as hours, + COALESCE(SUM(COALESCE(br.hourly_rate * ts.hours, 0)), 0) as amount + FROM projects.timesheets ts + LEFT JOIN LATERAL ( + SELECT hourly_rate + FROM projects.billing_rates br2 + WHERE br2.tenant_id = ts.tenant_id + AND br2.company_id = ts.company_id + AND br2.active = true + AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) + AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) + AND ( + (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR + (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR + (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR + (br2.project_id IS NULL AND br2.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 + WHEN br2.project_id IS NOT NULL THEN 2 + WHEN br2.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1 + ) br ON true + WHERE ts.tenant_id = $1 + AND ts.project_id = $2 + AND ts.status = 'approved' + AND ts.billable = true + AND ts.invoice_line_id IS NULL`, + [tenantId, projectId] + ); + + // Get related invoices + const invoices = await query<{ id: string; number: string; date: Date; amount: number; status: string }>( + `SELECT DISTINCT i.id, i.number, i.invoice_date as date, i.amount_total as amount, i.status + FROM financial.invoices i + JOIN financial.invoice_lines il ON il.invoice_id = i.id + JOIN projects.timesheets ts ON ts.invoice_line_id = il.id + WHERE ts.tenant_id = $1 AND ts.project_id = $2 + ORDER BY i.invoice_date DESC`, + [tenantId, projectId] + ); + + return { + total_hours_billed: parseFloat(billedStats?.hours || '0'), + total_amount_billed: parseFloat(billedStats?.amount || '0'), + unbilled_hours: parseFloat(unbilledStats?.hours || '0'), + unbilled_amount: parseFloat(unbilledStats?.amount || '0'), + invoices, + }; + } +} + +export const billingService = new BillingService(); diff --git a/src/modules/projects/hr-integration.service.ts b/src/modules/projects/hr-integration.service.ts new file mode 100644 index 0000000..bf59bca --- /dev/null +++ b/src/modules/projects/hr-integration.service.ts @@ -0,0 +1,641 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface EmployeeInfo { + employee_id: string; + employee_number: string; + employee_name: string; + user_id: string; + department_id?: string; + department_name?: string; + job_position_id?: string; + job_position_name?: string; + status: string; +} + +export interface EmployeeCostRate { + employee_id: string; + employee_name: string; + user_id: string; + hourly_cost: number; + monthly_cost: number; + currency_id?: string; + currency_code?: string; + source: 'contract' | 'cost_rate' | 'default'; + effective_date?: Date; +} + +export interface EmployeeTimesheetSummary { + employee_id: string; + employee_name: string; + user_id: string; + project_id: string; + project_name: string; + total_hours: number; + billable_hours: number; + non_billable_hours: number; + billable_amount: number; + cost_amount: number; + margin: number; + margin_percent: number; +} + +export interface ProjectProfitability { + project_id: string; + project_name: string; + partner_id?: string; + partner_name?: string; + total_hours: number; + billable_hours: number; + billable_revenue: number; + employee_cost: number; + gross_margin: number; + margin_percent: number; + by_employee: { + employee_id: string; + employee_name: string; + hours: number; + billable_hours: number; + revenue: number; + cost: number; + margin: number; + }[]; +} + +export interface CreateCostRateDto { + company_id: string; + employee_id: string; + hourly_cost: number; + currency_id: string; + effective_from?: string; + effective_to?: string; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class HRIntegrationService { + // -------------------------------------------------------------------------- + // EMPLOYEE LOOKUPS + // -------------------------------------------------------------------------- + + /** + * Get employee by user_id + */ + async getEmployeeByUserId( + userId: string, + tenantId: string + ): Promise { + const employee = await queryOne( + `SELECT e.id as employee_id, e.employee_number, + CONCAT(e.first_name, ' ', e.last_name) as employee_name, + e.user_id, e.department_id, d.name as department_name, + e.job_position_id, j.name as job_position_name, e.status + FROM hr.employees e + LEFT JOIN hr.departments d ON e.department_id = d.id + LEFT JOIN hr.job_positions j ON e.job_position_id = j.id + WHERE e.user_id = $1 AND e.tenant_id = $2 AND e.status = 'active'`, + [userId, tenantId] + ); + + return employee; + } + + /** + * Get all employees with user accounts + */ + async getEmployeesWithUserAccounts( + tenantId: string, + companyId?: string + ): Promise { + let whereClause = 'WHERE e.tenant_id = $1 AND e.user_id IS NOT NULL AND e.status = \'active\''; + const params: any[] = [tenantId]; + + if (companyId) { + whereClause += ' AND e.company_id = $2'; + params.push(companyId); + } + + return query( + `SELECT e.id as employee_id, e.employee_number, + CONCAT(e.first_name, ' ', e.last_name) as employee_name, + e.user_id, e.department_id, d.name as department_name, + e.job_position_id, j.name as job_position_name, e.status + FROM hr.employees e + LEFT JOIN hr.departments d ON e.department_id = d.id + LEFT JOIN hr.job_positions j ON e.job_position_id = j.id + ${whereClause} + ORDER BY e.last_name, e.first_name`, + params + ); + } + + // -------------------------------------------------------------------------- + // COST RATES + // -------------------------------------------------------------------------- + + /** + * Get employee cost rate + * Priority: explicit cost_rate > active contract wage > default + */ + async getEmployeeCostRate( + employeeId: string, + tenantId: string, + date?: Date + ): Promise { + const targetDate = date || new Date(); + + // First check explicit cost rates table + const explicitRate = await queryOne<{ + hourly_cost: string; + currency_id: string; + currency_code: string; + effective_from: Date; + }>( + `SELECT ecr.hourly_cost, ecr.currency_id, c.code as currency_code, ecr.effective_from + FROM hr.employee_cost_rates ecr + LEFT JOIN core.currencies c ON ecr.currency_id = c.id + WHERE ecr.employee_id = $1 + AND ecr.tenant_id = $2 + AND ecr.active = true + AND (ecr.effective_from IS NULL OR ecr.effective_from <= $3) + AND (ecr.effective_to IS NULL OR ecr.effective_to >= $3) + ORDER BY ecr.effective_from DESC NULLS LAST + LIMIT 1`, + [employeeId, tenantId, targetDate] + ); + + // Get employee info + const employee = await queryOne<{ + employee_id: string; + employee_name: string; + user_id: string; + }>( + `SELECT id as employee_id, + CONCAT(first_name, ' ', last_name) as employee_name, + user_id + FROM hr.employees + WHERE id = $1 AND tenant_id = $2`, + [employeeId, tenantId] + ); + + if (!employee) { + return null; + } + + if (explicitRate) { + const hourlyCost = parseFloat(explicitRate.hourly_cost); + return { + employee_id: employee.employee_id, + employee_name: employee.employee_name, + user_id: employee.user_id, + hourly_cost: hourlyCost, + monthly_cost: hourlyCost * 173.33, // ~40hrs/week * 4.33 weeks + currency_id: explicitRate.currency_id, + currency_code: explicitRate.currency_code, + source: 'cost_rate', + effective_date: explicitRate.effective_from, + }; + } + + // Fallback to contract wage + const contract = await queryOne<{ + wage: string; + wage_type: string; + hours_per_week: string; + currency_id: string; + currency_code: string; + }>( + `SELECT c.wage, c.wage_type, c.hours_per_week, c.currency_id, cur.code as currency_code + FROM hr.contracts c + LEFT JOIN core.currencies cur ON c.currency_id = cur.id + WHERE c.employee_id = $1 + AND c.tenant_id = $2 + AND c.status = 'active' + AND c.date_start <= $3 + AND (c.date_end IS NULL OR c.date_end >= $3) + ORDER BY c.date_start DESC + LIMIT 1`, + [employeeId, tenantId, targetDate] + ); + + if (contract) { + const wage = parseFloat(contract.wage); + const hoursPerWeek = parseFloat(contract.hours_per_week) || 40; + const wageType = contract.wage_type || 'monthly'; + + let hourlyCost: number; + let monthlyCost: number; + + if (wageType === 'hourly') { + hourlyCost = wage; + monthlyCost = wage * hoursPerWeek * 4.33; + } else if (wageType === 'daily') { + hourlyCost = wage / 8; + monthlyCost = wage * 21.67; // avg workdays per month + } else { + // monthly (default) + monthlyCost = wage; + hourlyCost = wage / (hoursPerWeek * 4.33); + } + + return { + employee_id: employee.employee_id, + employee_name: employee.employee_name, + user_id: employee.user_id, + hourly_cost: hourlyCost, + monthly_cost: monthlyCost, + currency_id: contract.currency_id, + currency_code: contract.currency_code, + source: 'contract', + }; + } + + // No cost data available - return with zero cost + return { + employee_id: employee.employee_id, + employee_name: employee.employee_name, + user_id: employee.user_id, + hourly_cost: 0, + monthly_cost: 0, + source: 'default', + }; + } + + /** + * Create or update employee cost rate + */ + async setEmployeeCostRate( + dto: CreateCostRateDto, + tenantId: string, + userId: string + ): Promise { + if (dto.hourly_cost < 0) { + throw new ValidationError('El costo por hora no puede ser negativo'); + } + + // Deactivate any existing rates for the same period + if (dto.effective_from) { + await query( + `UPDATE hr.employee_cost_rates + SET active = false, updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE employee_id = $2 AND tenant_id = $3 AND active = true + AND (effective_from IS NULL OR effective_from >= $4)`, + [userId, dto.employee_id, tenantId, dto.effective_from] + ); + } + + await query( + `INSERT INTO hr.employee_cost_rates ( + tenant_id, company_id, employee_id, hourly_cost, currency_id, + effective_from, effective_to, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + tenantId, dto.company_id, dto.employee_id, dto.hourly_cost, + dto.currency_id, dto.effective_from, dto.effective_to, userId + ] + ); + + logger.info('Employee cost rate set', { + employee_id: dto.employee_id, + hourly_cost: dto.hourly_cost, + }); + } + + // -------------------------------------------------------------------------- + // PROJECT PROFITABILITY + // -------------------------------------------------------------------------- + + /** + * Calculate project profitability + */ + async getProjectProfitability( + tenantId: string, + projectId: string + ): Promise { + // Get project info + const project = await queryOne<{ + id: string; + name: string; + partner_id: string; + partner_name: string; + }>( + `SELECT p.id, p.name, p.partner_id, pr.name as partner_name + FROM projects.projects p + LEFT JOIN core.partners pr ON p.partner_id = pr.id + WHERE p.id = $1 AND p.tenant_id = $2`, + [projectId, tenantId] + ); + + if (!project) { + throw new NotFoundError('Proyecto no encontrado'); + } + + // Get timesheets with billing info + const timesheets = await query<{ + user_id: string; + hours: string; + billable: boolean; + hourly_rate: string; + billable_amount: string; + }>( + `SELECT ts.user_id, ts.hours, ts.billable, + COALESCE(br.hourly_rate, 0) as hourly_rate, + COALESCE(br.hourly_rate * ts.hours, 0) as billable_amount + FROM projects.timesheets ts + LEFT JOIN LATERAL ( + SELECT hourly_rate + FROM projects.billing_rates br2 + WHERE br2.tenant_id = ts.tenant_id + AND br2.company_id = ts.company_id + AND br2.active = true + AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) + AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) + AND ( + (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR + (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR + (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR + (br2.project_id IS NULL AND br2.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 + WHEN br2.project_id IS NOT NULL THEN 2 + WHEN br2.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1 + ) br ON true + WHERE ts.project_id = $1 AND ts.tenant_id = $2 AND ts.status = 'approved'`, + [projectId, tenantId] + ); + + // Get unique users with their employees + const userIds = [...new Set(timesheets.map(ts => ts.user_id))]; + + // Get employee cost rates for each user + const employeeCosts = new Map(); + + for (const userId of userIds) { + const employee = await this.getEmployeeByUserId(userId, tenantId); + if (employee) { + const costRate = await this.getEmployeeCostRate(employee.employee_id, tenantId); + if (costRate) { + employeeCosts.set(userId, { + employee_id: employee.employee_id, + employee_name: employee.employee_name, + hourly_cost: costRate.hourly_cost, + }); + } + } + } + + // Calculate by employee + const byEmployeeMap = new Map(); + + let totalHours = 0; + let billableHours = 0; + let totalRevenue = 0; + let totalCost = 0; + + for (const ts of timesheets) { + const hours = parseFloat(ts.hours); + const billableAmount = parseFloat(ts.billable_amount); + const employeeInfo = employeeCosts.get(ts.user_id); + const hourlyCost = employeeInfo?.hourly_cost || 0; + const cost = hours * hourlyCost; + + totalHours += hours; + totalCost += cost; + + if (ts.billable) { + billableHours += hours; + totalRevenue += billableAmount; + } + + const key = ts.user_id; + const existing = byEmployeeMap.get(key); + + if (existing) { + existing.hours += hours; + if (ts.billable) { + existing.billable_hours += hours; + existing.revenue += billableAmount; + } + existing.cost += cost; + } else { + byEmployeeMap.set(key, { + employee_id: employeeInfo?.employee_id || ts.user_id, + employee_name: employeeInfo?.employee_name || 'Unknown', + hours, + billable_hours: ts.billable ? hours : 0, + revenue: ts.billable ? billableAmount : 0, + cost, + }); + } + } + + const grossMargin = totalRevenue - totalCost; + const marginPercent = totalRevenue > 0 ? (grossMargin / totalRevenue) * 100 : 0; + + const byEmployee = Array.from(byEmployeeMap.values()).map(emp => ({ + ...emp, + margin: emp.revenue - emp.cost, + })).sort((a, b) => b.hours - a.hours); + + return { + project_id: project.id, + project_name: project.name, + partner_id: project.partner_id, + partner_name: project.partner_name, + total_hours: totalHours, + billable_hours: billableHours, + billable_revenue: totalRevenue, + employee_cost: totalCost, + gross_margin: grossMargin, + margin_percent: marginPercent, + by_employee: byEmployee, + }; + } + + /** + * Get employee timesheet summary across projects + */ + async getEmployeeTimesheetSummary( + tenantId: string, + employeeId: string, + dateFrom?: string, + dateTo?: string + ): Promise { + // Get employee with user_id + const employee = await queryOne<{ + employee_id: string; + employee_name: string; + user_id: string; + }>( + `SELECT id as employee_id, + CONCAT(first_name, ' ', last_name) as employee_name, + user_id + FROM hr.employees + WHERE id = $1 AND tenant_id = $2`, + [employeeId, tenantId] + ); + + if (!employee || !employee.user_id) { + throw new NotFoundError('Empleado no encontrado o sin usuario asociado'); + } + + // Get cost rate + const costRate = await this.getEmployeeCostRate(employeeId, tenantId); + const hourlyCost = costRate?.hourly_cost || 0; + + let whereClause = `WHERE ts.user_id = $1 AND ts.tenant_id = $2 AND ts.status = 'approved'`; + const params: any[] = [employee.user_id, tenantId]; + let paramIndex = 3; + + if (dateFrom) { + whereClause += ` AND ts.date >= $${paramIndex++}`; + params.push(dateFrom); + } + + if (dateTo) { + whereClause += ` AND ts.date <= $${paramIndex++}`; + params.push(dateTo); + } + + const timesheets = await query<{ + project_id: string; + project_name: string; + total_hours: string; + billable_hours: string; + non_billable_hours: string; + billable_amount: string; + }>( + `SELECT ts.project_id, p.name as project_name, + SUM(ts.hours) as total_hours, + SUM(CASE WHEN ts.billable THEN ts.hours ELSE 0 END) as billable_hours, + SUM(CASE WHEN NOT ts.billable THEN ts.hours ELSE 0 END) as non_billable_hours, + SUM(CASE WHEN ts.billable THEN COALESCE(br.hourly_rate * ts.hours, 0) ELSE 0 END) as billable_amount + FROM projects.timesheets ts + JOIN projects.projects p ON ts.project_id = p.id + LEFT JOIN LATERAL ( + SELECT hourly_rate + FROM projects.billing_rates br2 + WHERE br2.tenant_id = ts.tenant_id + AND br2.company_id = ts.company_id + AND br2.active = true + AND (br2.effective_from IS NULL OR br2.effective_from <= ts.date) + AND (br2.effective_to IS NULL OR br2.effective_to >= ts.date) + AND ( + (br2.project_id = ts.project_id AND br2.user_id = ts.user_id) OR + (br2.project_id = ts.project_id AND br2.user_id IS NULL) OR + (br2.project_id IS NULL AND br2.user_id = ts.user_id) OR + (br2.project_id IS NULL AND br2.user_id IS NULL) + ) + ORDER BY + CASE + WHEN br2.project_id IS NOT NULL AND br2.user_id IS NOT NULL THEN 1 + WHEN br2.project_id IS NOT NULL THEN 2 + WHEN br2.user_id IS NOT NULL THEN 3 + ELSE 4 + END + LIMIT 1 + ) br ON true + ${whereClause} + GROUP BY ts.project_id, p.name + ORDER BY total_hours DESC`, + params + ); + + return timesheets.map(ts => { + const totalHours = parseFloat(ts.total_hours); + const billableHours = parseFloat(ts.billable_hours); + const billableAmount = parseFloat(ts.billable_amount); + const costAmount = totalHours * hourlyCost; + const margin = billableAmount - costAmount; + + return { + employee_id: employee.employee_id, + employee_name: employee.employee_name, + user_id: employee.user_id, + project_id: ts.project_id, + project_name: ts.project_name, + total_hours: totalHours, + billable_hours: billableHours, + non_billable_hours: parseFloat(ts.non_billable_hours), + billable_amount: billableAmount, + cost_amount: costAmount, + margin, + margin_percent: billableAmount > 0 ? (margin / billableAmount) * 100 : 0, + }; + }); + } + + /** + * Validate project team assignment + */ + async validateTeamAssignment( + tenantId: string, + projectId: string, + userId: string + ): Promise<{ valid: boolean; employee?: EmployeeInfo; message?: string }> { + // Check if user has an employee record + const employee = await this.getEmployeeByUserId(userId, tenantId); + + if (!employee) { + return { + valid: false, + message: 'El usuario no tiene un registro de empleado activo', + }; + } + + // Get project info + const project = await queryOne<{ company_id: string }>( + `SELECT company_id FROM projects.projects WHERE id = $1 AND tenant_id = $2`, + [projectId, tenantId] + ); + + if (!project) { + return { + valid: false, + message: 'Proyecto no encontrado', + }; + } + + // Optionally check if employee belongs to the same company + // This is a soft validation - you might want to allow cross-company assignments + const employeeCompany = await queryOne<{ company_id: string }>( + `SELECT company_id FROM hr.employees WHERE id = $1`, + [employee.employee_id] + ); + + if (employeeCompany && employeeCompany.company_id !== project.company_id) { + return { + valid: true, // Still valid but with warning + employee, + message: 'El empleado pertenece a una compañía diferente al proyecto', + }; + } + + return { + valid: true, + employee, + }; + } +} + +export const hrIntegrationService = new HRIntegrationService(); diff --git a/src/modules/projects/index.ts b/src/modules/projects/index.ts index 8b83332..94e7545 100644 --- a/src/modules/projects/index.ts +++ b/src/modules/projects/index.ts @@ -1,5 +1,7 @@ export * from './projects.service.js'; export * from './tasks.service.js'; export * from './timesheets.service.js'; +export * from './billing.service.js'; +export * from './hr-integration.service.js'; export * from './projects.controller.js'; export { default as projectsRoutes } from './projects.routes.js'; diff --git a/src/modules/purchases/purchases.service.ts b/src/modules/purchases/purchases.service.ts index 4a59f70..7630fe2 100644 --- a/src/modules/purchases/purchases.service.ts +++ b/src/modules/purchases/purchases.service.ts @@ -1,5 +1,7 @@ import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; +import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; +import { logger } from '../../shared/utils/logger.js'; export type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled'; @@ -51,10 +53,9 @@ export interface PurchaseOrder { export interface CreatePurchaseOrderDto { company_id: string; - name: string; ref?: string; partner_id: string; - order_date: string; + order_date?: string; // Made optional, defaults to today expected_date?: string; currency_id: string; payment_term_id?: string; @@ -193,6 +194,10 @@ class PurchasesService { throw new ValidationError('La orden de compra debe tener al menos una línea'); } + // TASK-004-01: Generate PO number using sequences service + const poNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.PURCHASE_ORDER, tenantId); + const orderDate = dto.order_date || new Date().toISOString().split('T')[0]; + const client = await getClient(); try { @@ -205,12 +210,12 @@ class PurchasesService { amountUntaxed += lineTotal; } - // Create order + // Create order with auto-generated PO number const orderResult = await client.query( `INSERT INTO purchase.purchase_orders (tenant_id, company_id, name, ref, partner_id, order_date, expected_date, currency_id, payment_term_id, amount_untaxed, amount_total, notes, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, - [tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.order_date, dto.expected_date, dto.currency_id, dto.payment_term_id, amountUntaxed, amountUntaxed, dto.notes, userId] + [tenantId, dto.company_id, poNumber, dto.ref, dto.partner_id, orderDate, dto.expected_date, dto.currency_id, dto.payment_term_id, amountUntaxed, amountUntaxed, dto.notes, userId] ); const order = orderResult.rows[0] as PurchaseOrder; @@ -226,9 +231,20 @@ class PurchasesService { await client.query('COMMIT'); + logger.info('Purchase order created', { + orderId: order.id, + orderName: poNumber, + tenantId, + createdBy: userId, + }); + return this.findById(order.id, tenantId); } catch (error) { await client.query('ROLLBACK'); + logger.error('Error creating purchase order', { + error: (error as Error).message, + tenantId, + }); throw error; } finally { client.release(); @@ -341,14 +357,132 @@ class PurchasesService { throw new ValidationError('La orden debe tener al menos una línea para confirmar'); } - await query( - `UPDATE purchase.purchase_orders - SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP, confirmed_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1 - WHERE id = $2 AND tenant_id = $3`, - [userId, id, tenantId] - ); + const client = await getClient(); + try { + await client.query('BEGIN'); - return this.findById(id, tenantId); + // TASK-004-03: Get supplier location and internal location + const supplierLocResult = await client.query( + `SELECT id FROM inventory.locations + WHERE tenant_id = $1 AND location_type = 'supplier' + LIMIT 1`, + [tenantId] + ); + + let supplierLocationId = supplierLocResult.rows[0]?.id; + + if (!supplierLocationId) { + // Create a default supplier location + const newLocResult = await client.query( + `INSERT INTO inventory.locations (tenant_id, name, location_type, active) + VALUES ($1, 'Suppliers', 'supplier', true) + RETURNING id`, + [tenantId] + ); + supplierLocationId = newLocResult.rows[0].id; + } + + // Get default incoming location for the company + const internalLocResult = await client.query( + `SELECT l.id as location_id, w.id as warehouse_id + FROM inventory.locations l + INNER JOIN inventory.warehouses w ON l.warehouse_id = w.id + WHERE w.tenant_id = $1 + AND w.company_id = $2 + AND l.location_type = 'internal' + AND l.active = true + ORDER BY w.is_default DESC, l.name ASC + LIMIT 1`, + [tenantId, order.company_id] + ); + + const destLocationId = internalLocResult.rows[0]?.location_id; + + if (!destLocationId) { + throw new ValidationError('No hay ubicación de stock configurada para esta empresa'); + } + + // Create incoming picking + const pickingNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.PICKING_IN, tenantId); + + const pickingResult = await client.query( + `INSERT INTO inventory.pickings ( + tenant_id, company_id, name, picking_type, location_id, location_dest_id, + partner_id, scheduled_date, origin, status, created_by + ) + VALUES ($1, $2, $3, 'incoming', $4, $5, $6, $7, $8, 'confirmed', $9) + RETURNING id`, + [ + tenantId, + order.company_id, + pickingNumber, + supplierLocationId, + destLocationId, + order.partner_id, + order.expected_date || new Date().toISOString().split('T')[0], + order.name, // origin = purchase order reference + userId, + ] + ); + const pickingId = pickingResult.rows[0].id; + + // Create stock moves for each order line + for (const line of order.lines) { + await client.query( + `INSERT INTO inventory.stock_moves ( + tenant_id, picking_id, product_id, product_uom_id, location_id, + location_dest_id, product_qty, status, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', $8)`, + [ + tenantId, + pickingId, + line.product_id, + line.uom_id, + supplierLocationId, + destLocationId, + line.quantity, + userId, + ] + ); + } + + // Update order status and link picking + await client.query( + `UPDATE purchase.purchase_orders SET + status = 'confirmed', + picking_id = $1, + confirmed_at = CURRENT_TIMESTAMP, + confirmed_by = $2, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3`, + [pickingId, userId, id] + ); + + await client.query('COMMIT'); + + logger.info('Purchase order confirmed with receipt picking', { + orderId: id, + orderName: order.name, + pickingId, + pickingName: pickingNumber, + linesCount: order.lines.length, + tenantId, + }); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error confirming purchase order', { + error: (error as Error).message, + orderId: id, + tenantId, + }); + throw error; + } finally { + client.release(); + } } async cancel(id: string, tenantId: string, userId: string): Promise { @@ -381,6 +515,134 @@ class PurchasesService { await query(`DELETE FROM purchase.purchase_orders WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); } + + /** + * TASK-004-05: Create supplier invoice (BILL) from confirmed purchase order + */ + async createSupplierInvoice(id: string, tenantId: string, userId: string): Promise<{ orderId: string; invoiceId: string }> { + const order = await this.findById(id, tenantId); + + if (order.status !== 'confirmed' && order.status !== 'done') { + throw new ValidationError('Solo se pueden facturar órdenes confirmadas'); + } + + if (order.invoice_status === 'invoiced') { + throw new ValidationError('La orden ya está completamente facturada'); + } + + // Check if there are quantities to invoice + const linesToInvoice = order.lines?.filter(l => { + const qtyReceived = l.qty_received || 0; + const qtyInvoiced = l.qty_invoiced || 0; + return qtyReceived > qtyInvoiced; + }); + + if (!linesToInvoice || linesToInvoice.length === 0) { + throw new ValidationError('No hay líneas recibidas para facturar'); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Generate invoice number using sequences + const invoiceNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.INVOICE_SUPPLIER, tenantId); + + // Calculate due date from payment terms + let dueDate = new Date(); + if (order.payment_term_id) { + const paymentTermResult = await client.query( + `SELECT due_days FROM core.payment_terms WHERE id = $1`, + [order.payment_term_id] + ); + const dueDays = paymentTermResult.rows[0]?.due_days || 30; + dueDate.setDate(dueDate.getDate() + dueDays); + } else { + dueDate.setDate(dueDate.getDate() + 30); // Default 30 days + } + + // Create invoice (supplier invoice = 'supplier' type) + const invoiceResult = await client.query( + `INSERT INTO financial.invoices ( + tenant_id, company_id, name, partner_id, invoice_date, due_date, + currency_id, invoice_type, amount_untaxed, amount_tax, amount_total, + source_document, created_by + ) + VALUES ($1, $2, $3, $4, CURRENT_DATE, $5, $6, 'supplier', 0, 0, 0, $7, $8) + RETURNING id`, + [tenantId, order.company_id, invoiceNumber, order.partner_id, dueDate.toISOString().split('T')[0], order.currency_id, order.name, userId] + ); + const invoiceId = invoiceResult.rows[0].id; + + // Create invoice lines and update qty_invoiced + for (const line of linesToInvoice) { + const qtyToInvoice = (line.qty_received || 0) - (line.qty_invoiced || 0); + const lineAmount = qtyToInvoice * line.price_unit * (1 - (line.discount || 0) / 100); + + await client.query( + `INSERT INTO financial.invoice_lines ( + invoice_id, tenant_id, product_id, description, quantity, uom_id, + price_unit, discount, amount_untaxed, amount_tax, amount_total + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $9)`, + [invoiceId, tenantId, line.product_id, line.description, qtyToInvoice, line.uom_id, line.price_unit, line.discount || 0, lineAmount] + ); + + await client.query( + `UPDATE purchase.purchase_order_lines SET qty_invoiced = COALESCE(qty_invoiced, 0) + $1 WHERE id = $2`, + [qtyToInvoice, line.id] + ); + } + + // Update invoice totals + await client.query( + `UPDATE financial.invoices SET + amount_untaxed = (SELECT COALESCE(SUM(amount_untaxed), 0) FROM financial.invoice_lines WHERE invoice_id = $1), + amount_total = (SELECT COALESCE(SUM(amount_total), 0) FROM financial.invoice_lines WHERE invoice_id = $1) + WHERE id = $1`, + [invoiceId] + ); + + // TASK-004-06: Update order invoice_status + await client.query( + `UPDATE purchase.purchase_orders SET + invoice_status = CASE + WHEN (SELECT COALESCE(SUM(qty_invoiced), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) >= + (SELECT COALESCE(SUM(quantity), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) + THEN 'invoiced' + WHEN (SELECT COALESCE(SUM(qty_invoiced), 0) FROM purchase.purchase_order_lines WHERE order_id = $1) > 0 + THEN 'partial' + ELSE 'pending' + END, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [id, userId] + ); + + await client.query('COMMIT'); + + logger.info('Supplier invoice created from purchase order', { + orderId: id, + orderName: order.name, + invoiceId, + invoiceName: invoiceNumber, + tenantId, + }); + + return { orderId: id, invoiceId }; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error creating supplier invoice', { + error: (error as Error).message, + orderId: id, + tenantId, + }); + throw error; + } finally { + client.release(); + } + } } export const purchasesService = new PurchasesService(); diff --git a/src/modules/sales/controllers/index.ts b/src/modules/sales/controllers/index.ts index 049434b..af52666 100644 --- a/src/modules/sales/controllers/index.ts +++ b/src/modules/sales/controllers/index.ts @@ -18,8 +18,8 @@ export class QuotationsController { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } - const { partnerId, status, salesRepId, limit, offset } = req.query; - const result = await this.salesService.findAllQuotations({ tenantId, partnerId: partnerId as string, status: status as string, salesRepId: salesRepId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + const { partnerId, status, userId, limit, offset } = req.query; + const result = await this.salesService.findAllQuotations({ tenantId, partnerId: partnerId as string, status: status as string, userId: userId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); res.json(result); } catch (e) { next(e); } } @@ -94,8 +94,8 @@ export class SalesOrdersController { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } - const { partnerId, status, salesRepId, limit, offset } = req.query; - const result = await this.salesService.findAllOrders({ tenantId, partnerId: partnerId as string, status: status as string, salesRepId: salesRepId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + const { partnerId, status, userId, limit, offset } = req.query; + const result = await this.salesService.findAllOrders({ tenantId, partnerId: partnerId as string, status: status as string, userId: userId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); res.json(result); } catch (e) { next(e); } } diff --git a/src/modules/sales/entities/sales-order.entity.ts b/src/modules/sales/entities/sales-order.entity.ts index 6d08bb1..f23829b 100644 --- a/src/modules/sales/entities/sales-order.entity.ts +++ b/src/modules/sales/entities/sales-order.entity.ts @@ -1,5 +1,16 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PaymentTerm } from '../../core/entities/payment-term.entity.js'; +/** + * Sales Order Entity + * + * Aligned with SQL schema used by orders.service.ts + * Supports full Order-to-Cash flow with: + * - PaymentTerms integration + * - Automatic picking creation + * - Stock reservation + * - Invoice and delivery status tracking + */ @Entity({ name: 'sales_orders', schema: 'sales' }) export class SalesOrder { @PrimaryGeneratedColumn('uuid') @@ -10,104 +21,123 @@ export class SalesOrder { tenantId: string; @Index() - @Column({ name: 'order_number', type: 'varchar', length: 30 }) - orderNumber: string; + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + // Order identification + @Index() + @Column({ type: 'varchar', length: 30 }) + name: string; // Order number (e.g., SO-000001) + + @Column({ name: 'client_order_ref', type: 'varchar', length: 100, nullable: true }) + clientOrderRef: string | null; // Customer's reference number @Column({ name: 'quotation_id', type: 'uuid', nullable: true }) - quotationId: string; + quotationId: string | null; + // Partner/Customer @Index() @Column({ name: 'partner_id', type: 'uuid' }) partnerId: string; - @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) - partnerName: string; - - @Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true }) - partnerEmail: string; - - @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) - billingAddress: object; - - @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) - shippingAddress: object; - + // Dates @Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' }) orderDate: Date; - @Column({ name: 'requested_date', type: 'date', nullable: true }) - requestedDate: Date; + @Column({ name: 'validity_date', type: 'date', nullable: true }) + validityDate: Date | null; - @Column({ name: 'promised_date', type: 'date', nullable: true }) - promisedDate: Date; + @Column({ name: 'commitment_date', type: 'date', nullable: true }) + commitmentDate: Date | null; // Promised delivery date - @Column({ name: 'shipped_date', type: 'date', nullable: true }) - shippedDate: Date; + // Currency and pricing + @Index() + @Column({ name: 'currency_id', type: 'uuid' }) + currencyId: string; - @Column({ name: 'delivered_date', type: 'date', nullable: true }) - deliveredDate: Date; + @Column({ name: 'pricelist_id', type: 'uuid', nullable: true }) + pricelistId: string | null; - @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) - salesRepId: string; + // Payment terms integration (TASK-003-01) + @Index() + @Column({ name: 'payment_term_id', type: 'uuid', nullable: true }) + paymentTermId: string | null; - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId: string; + @ManyToOne(() => PaymentTerm) + @JoinColumn({ name: 'payment_term_id' }) + paymentTerm: PaymentTerm; - @Column({ type: 'varchar', length: 3, default: 'MXN' }) - currency: string; + // Sales team + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; // Sales representative - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - subtotal: number; + @Column({ name: 'sales_team_id', type: 'uuid', nullable: true }) + salesTeamId: string | null; - @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - taxAmount: number; + // Amounts + @Column({ name: 'amount_untaxed', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountUntaxed: number; - @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - discountAmount: number; + @Column({ name: 'amount_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountTax: number; - @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) - shippingAmount: number; - - @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) - total: number; - - @Column({ name: 'payment_term_days', type: 'int', default: 0 }) - paymentTermDays: number; - - @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) - paymentMethod: string; + @Column({ name: 'amount_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountTotal: number; + // Status fields (Order-to-Cash tracking) @Index() @Column({ type: 'varchar', length: 20, default: 'draft' }) - status: 'draft' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled'; - @Column({ name: 'shipping_method', type: 'varchar', length: 50, nullable: true }) - shippingMethod: string; + @Index() + @Column({ name: 'invoice_status', type: 'varchar', length: 20, default: 'pending' }) + invoiceStatus: 'pending' | 'partial' | 'invoiced'; - @Column({ name: 'tracking_number', type: 'varchar', length: 100, nullable: true }) - trackingNumber: string; + @Index() + @Column({ name: 'delivery_status', type: 'varchar', length: 20, default: 'pending' }) + deliveryStatus: 'pending' | 'partial' | 'delivered'; - @Column({ type: 'varchar', length: 100, nullable: true }) - carrier: string; + @Column({ name: 'invoice_policy', type: 'varchar', length: 20, default: 'order' }) + invoicePolicy: 'order' | 'delivery'; + // Delivery/Picking integration (TASK-003-03) + @Column({ name: 'picking_id', type: 'uuid', nullable: true }) + pickingId: string | null; + + // Notes @Column({ type: 'text', nullable: true }) - notes: string; + notes: string | null; - @Column({ name: 'internal_notes', type: 'text', nullable: true }) - internalNotes: string; + @Column({ name: 'terms_conditions', type: 'text', nullable: true }) + termsConditions: string | null; + // Confirmation tracking + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt: Date | null; + + @Column({ name: 'confirmed_by', type: 'uuid', nullable: true }) + confirmedBy: string | null; + + // Cancellation tracking + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date | null; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string | null; + + // Audit fields @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy?: string; + createdBy: string | null; @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy?: string; + updatedBy: string | null; @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) - deletedAt: Date; + deletedAt: Date | null; } diff --git a/src/modules/sales/orders.service.ts b/src/modules/sales/orders.service.ts index cca04fc..1caeb92 100644 --- a/src/modules/sales/orders.service.ts +++ b/src/modules/sales/orders.service.ts @@ -2,6 +2,8 @@ import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; import { taxesService } from '../financial/taxes.service.js'; import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; +import { stockReservationService, ReservationLine } from '../inventory/stock-reservation.service.js'; +import { logger } from '../../shared/utils/logger.js'; export interface SalesOrderLine { id: string; @@ -524,26 +526,146 @@ class OrdersService { try { await client.query('BEGIN'); - // Update order status to 'sent' (Odoo-compatible: quotation sent to customer) + // Get default outgoing location for the company + const locationResult = await client.query( + `SELECT l.id as location_id, w.id as warehouse_id + FROM inventory.locations l + INNER JOIN inventory.warehouses w ON l.warehouse_id = w.id + WHERE w.tenant_id = $1 + AND w.company_id = $2 + AND l.location_type = 'internal' + AND l.active = true + ORDER BY w.is_default DESC, l.name ASC + LIMIT 1`, + [tenantId, order.company_id] + ); + + const sourceLocationId = locationResult.rows[0]?.location_id; + const warehouseId = locationResult.rows[0]?.warehouse_id; + + if (!sourceLocationId) { + throw new ValidationError('No hay ubicación de stock configurada para esta empresa'); + } + + // Get customer location (or create virtual one) + const custLocationResult = await client.query( + `SELECT id FROM inventory.locations + WHERE tenant_id = $1 AND location_type = 'customer' + LIMIT 1`, + [tenantId] + ); + + let customerLocationId = custLocationResult.rows[0]?.id; + + if (!customerLocationId) { + // Create a default customer location + const newLocResult = await client.query( + `INSERT INTO inventory.locations (tenant_id, name, location_type, active) + VALUES ($1, 'Customers', 'customer', true) + RETURNING id`, + [tenantId] + ); + customerLocationId = newLocResult.rows[0].id; + } + + // TASK-003-04: Reserve stock for order lines + const reservationLines: ReservationLine[] = order.lines.map(line => ({ + productId: line.product_id, + locationId: sourceLocationId, + quantity: line.quantity, + })); + + const reservationResult = await stockReservationService.reserveWithClient( + client, + reservationLines, + tenantId, + order.name, + false // Don't allow partial - fail if insufficient stock + ); + + if (!reservationResult.success) { + throw new ValidationError( + `Stock insuficiente: ${reservationResult.errors.join(', ')}` + ); + } + + // TASK-003-03: Create outgoing picking + const pickingNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.PICKING_OUT, tenantId); + + const pickingResult = await client.query( + `INSERT INTO inventory.pickings ( + tenant_id, company_id, name, picking_type, location_id, location_dest_id, + partner_id, scheduled_date, origin, status, created_by + ) + VALUES ($1, $2, $3, 'outgoing', $4, $5, $6, $7, $8, 'confirmed', $9) + RETURNING id`, + [ + tenantId, + order.company_id, + pickingNumber, + sourceLocationId, + customerLocationId, + order.partner_id, + order.commitment_date || new Date().toISOString().split('T')[0], + order.name, // origin = sales order reference + userId, + ] + ); + const pickingId = pickingResult.rows[0].id; + + // Create stock moves for each order line + for (const line of order.lines) { + await client.query( + `INSERT INTO inventory.stock_moves ( + tenant_id, picking_id, product_id, product_uom_id, location_id, + location_dest_id, product_qty, status, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'confirmed', $8)`, + [ + tenantId, + pickingId, + line.product_id, + line.uom_id, + sourceLocationId, + customerLocationId, + line.quantity, + userId, + ] + ); + } + + // Update order: status to 'sent', link picking await client.query( `UPDATE sales.sales_orders SET status = 'sent', + picking_id = $1, confirmed_at = CURRENT_TIMESTAMP, - confirmed_by = $1, - updated_by = $1, + confirmed_by = $2, + updated_by = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $2`, - [userId, id] + WHERE id = $3`, + [pickingId, userId, id] ); - // Create delivery picking (optional - depends on business logic) - // This would create an inventory.pickings record for delivery - await client.query('COMMIT'); + logger.info('Sales order confirmed with picking', { + orderId: id, + orderName: order.name, + pickingId, + pickingName: pickingNumber, + linesCount: order.lines.length, + tenantId, + }); + return this.findById(id, tenantId); } catch (error) { await client.query('ROLLBACK'); + logger.error('Error confirming sales order', { + error: (error as Error).message, + orderId: id, + tenantId, + }); throw error; } finally { client.release(); @@ -570,18 +692,78 @@ class OrdersService { throw new ValidationError('No se puede cancelar: ya hay facturas asociadas'); } - await query( - `UPDATE sales.sales_orders SET - status = 'cancelled', - cancelled_at = CURRENT_TIMESTAMP, - cancelled_by = $1, - updated_by = $1, - updated_at = CURRENT_TIMESTAMP - WHERE id = $2 AND tenant_id = $3`, - [userId, id, tenantId] - ); + const client = await getClient(); + try { + await client.query('BEGIN'); - return this.findById(id, tenantId); + // Release stock reservations if order was confirmed + if (order.status === 'sent' || order.status === 'sale') { + // Get the source location from picking + if (order.picking_id) { + const pickingResult = await client.query( + `SELECT location_id FROM inventory.pickings WHERE id = $1`, + [order.picking_id] + ); + const sourceLocationId = pickingResult.rows[0]?.location_id; + + if (sourceLocationId && order.lines) { + const releaseLines: ReservationLine[] = order.lines.map(line => ({ + productId: line.product_id, + locationId: sourceLocationId, + quantity: line.quantity, + })); + + await stockReservationService.releaseWithClient( + client, + releaseLines, + tenantId + ); + } + + // Cancel the picking + await client.query( + `UPDATE inventory.pickings SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, + [userId, order.picking_id] + ); + await client.query( + `UPDATE inventory.stock_moves SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE picking_id = $2`, + [userId, order.picking_id] + ); + } + } + + // Update order status + await client.query( + `UPDATE sales.sales_orders SET + status = 'cancelled', + cancelled_at = CURRENT_TIMESTAMP, + cancelled_by = $1, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + await client.query('COMMIT'); + + logger.info('Sales order cancelled', { + orderId: id, + orderName: order.name, + tenantId, + }); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error cancelling sales order', { + error: (error as Error).message, + orderId: id, + tenantId, + }); + throw error; + } finally { + client.release(); + } } async createInvoice(id: string, tenantId: string, userId: string): Promise<{ orderId: string; invoiceId: string }> { diff --git a/src/modules/sales/services/index.ts b/src/modules/sales/services/index.ts index fcde7fd..29d721c 100644 --- a/src/modules/sales/services/index.ts +++ b/src/modules/sales/services/index.ts @@ -1,19 +1,28 @@ import { Repository, FindOptionsWhere, ILike } from 'typeorm'; -import { Quotation, SalesOrder } from '../entities'; -import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto'; +import { Quotation, SalesOrder } from '../entities/index.js'; +import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto/index.js'; +/** + * @deprecated Use ordersService from '../orders.service.js' for full Order-to-Cash flow + * This TypeORM-based service provides basic CRUD operations. + * For advanced features (stock reservation, auto-picking, delivery tracking), + * use the SQL-based ordersService instead. + */ export interface SalesSearchParams { tenantId: string; search?: string; partnerId?: string; status?: string; - salesRepId?: string; + userId?: string; // Changed from salesRepId to match entity fromDate?: Date; toDate?: Date; limit?: number; offset?: number; } +/** + * @deprecated Use ordersService from '../orders.service.js' for full Order-to-Cash flow + */ export class SalesService { constructor( private readonly quotationRepository: Repository, @@ -21,11 +30,11 @@ export class SalesService { ) {} async findAllQuotations(params: SalesSearchParams): Promise<{ data: Quotation[]; total: number }> { - const { tenantId, search, partnerId, status, salesRepId, limit = 50, offset = 0 } = params; + const { tenantId, search, partnerId, status, userId, limit = 50, offset = 0 } = params; const where: FindOptionsWhere = { tenantId }; if (partnerId) where.partnerId = partnerId; if (status) where.status = status as any; - if (salesRepId) where.salesRepId = salesRepId; + if (userId) where.salesRepId = userId; const [data, total] = await this.quotationRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); return { data, total }; } @@ -53,6 +62,9 @@ export class SalesService { return (result.affected ?? 0) > 0; } + /** + * @deprecated Use ordersService.confirm() for proper picking and stock flow + */ async convertQuotationToOrder(id: string, tenantId: string, userId?: string): Promise { const quotation = await this.findQuotation(id, tenantId); if (!quotation) throw new Error('Quotation not found'); @@ -60,13 +72,7 @@ export class SalesService { const order = await this.createSalesOrder(tenantId, { partnerId: quotation.partnerId, - partnerName: quotation.partnerName, quotationId: quotation.id, - billingAddress: quotation.billingAddress, - shippingAddress: quotation.shippingAddress, - currency: quotation.currency, - paymentTermDays: quotation.paymentTermDays, - paymentMethod: quotation.paymentMethod, notes: quotation.notes, }, userId); @@ -80,11 +86,11 @@ export class SalesService { } async findAllOrders(params: SalesSearchParams): Promise<{ data: SalesOrder[]; total: number }> { - const { tenantId, search, partnerId, status, salesRepId, limit = 50, offset = 0 } = params; + const { tenantId, search, partnerId, status, userId, limit = 50, offset = 0 } = params; const where: FindOptionsWhere = { tenantId }; if (partnerId) where.partnerId = partnerId; if (status) where.status = status as any; - if (salesRepId) where.salesRepId = salesRepId; + if (userId) where.userId = userId; const [data, total] = await this.orderRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); return { data, total }; } @@ -93,17 +99,33 @@ export class SalesService { return this.orderRepository.findOne({ where: { id, tenantId } }); } + /** + * @deprecated Use ordersService.create() for proper sequence generation + */ async createSalesOrder(tenantId: string, dto: CreateSalesOrderDto, createdBy?: string): Promise { const count = await this.orderRepository.count({ where: { tenantId } }); - const orderNumber = `OV-${String(count + 1).padStart(6, '0')}`; - const order = this.orderRepository.create({ ...dto, tenantId, orderNumber, createdBy, orderDate: new Date(), requestedDate: dto.requestedDate ? new Date(dto.requestedDate) : undefined, promisedDate: dto.promisedDate ? new Date(dto.promisedDate) : undefined }); + const orderName = `SO-${String(count + 1).padStart(6, '0')}`; + const orderData: Partial = { + tenantId, + companyId: (dto as any).companyId || '00000000-0000-0000-0000-000000000000', + name: orderName, + partnerId: dto.partnerId, + quotationId: dto.quotationId || null, + currencyId: (dto as any).currencyId || '00000000-0000-0000-0000-000000000000', + orderDate: new Date(), + commitmentDate: dto.promisedDate ? new Date(dto.promisedDate) : null, + notes: dto.notes || null, + createdBy: createdBy || null, + }; + const order = this.orderRepository.create(orderData as SalesOrder); return this.orderRepository.save(order); } async updateSalesOrder(id: string, tenantId: string, dto: UpdateSalesOrderDto, updatedBy?: string): Promise { const order = await this.findOrder(id, tenantId); if (!order) return null; - Object.assign(order, { ...dto, updatedBy }); + if (dto.notes !== undefined) order.notes = dto.notes || null; + order.updatedBy = updatedBy || null; return this.orderRepository.save(order); } @@ -112,31 +134,38 @@ export class SalesService { return (result.affected ?? 0) > 0; } + /** + * @deprecated Use ordersService.confirm() for proper picking and stock reservation + */ async confirmOrder(id: string, tenantId: string, userId?: string): Promise { const order = await this.findOrder(id, tenantId); if (!order || order.status !== 'draft') return null; - order.status = 'confirmed'; - order.updatedBy = userId; + order.status = 'sent'; // Changed from 'confirmed' to match entity enum + order.updatedBy = userId || null; return this.orderRepository.save(order); } - async shipOrder(id: string, tenantId: string, trackingNumber?: string, carrier?: string, userId?: string): Promise { + /** + * @deprecated Use pickings validation flow for proper delivery tracking + */ + async shipOrder(id: string, tenantId: string, _trackingNumber?: string, _carrier?: string, userId?: string): Promise { const order = await this.findOrder(id, tenantId); - if (!order || !['confirmed', 'processing'].includes(order.status)) return null; - order.status = 'shipped'; - order.shippedDate = new Date(); - if (trackingNumber) order.trackingNumber = trackingNumber; - if (carrier) order.carrier = carrier; - order.updatedBy = userId; + if (!order || order.status !== 'sent') return null; + order.status = 'sale'; + order.deliveryStatus = 'partial'; + order.updatedBy = userId || null; return this.orderRepository.save(order); } + /** + * @deprecated Use pickings validation flow for proper delivery tracking + */ async deliverOrder(id: string, tenantId: string, userId?: string): Promise { const order = await this.findOrder(id, tenantId); - if (!order || order.status !== 'shipped') return null; - order.status = 'delivered'; - order.deliveredDate = new Date(); - order.updatedBy = userId; + if (!order || order.status !== 'sale') return null; + order.status = 'done'; + order.deliveryStatus = 'delivered'; + order.updatedBy = userId || null; return this.orderRepository.save(order); } }