[FASE 3-4] feat: Complete Financial, Inventory, CRM, and Projects modules
EPIC-005 - Financial Module: - Add AccountMapping entity for GL account configuration - Create GLPostingService for automatic journal entries - Integrate GL posting with invoice validation - Fix tax calculation in invoice lines EPIC-006 - Inventory Automation: - Integrate FIFO valuation with pickings - Create ReorderAlertsService for stock monitoring - Add lot validation for tracked products - Integrate valuation with inventory adjustments EPIC-007 - CRM Improvements: - Create ActivitiesService for activity management - Create ForecastingService for pipeline analytics - Add win/loss reporting and user performance metrics EPIC-008 - Project Billing: - Create BillingService with billing rate management - Add getUnbilledTimesheets and createInvoiceFromTimesheets - Support grouping options for invoice generation EPIC-009 - HR-Projects Integration: - Create HRIntegrationService for employee-user linking - Add employee cost rate management - Implement project profitability calculations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6054102774
commit
edadaf3180
@ -14,7 +14,7 @@ export function createMockRepository<T>() {
|
||||
softDelete: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => createMockQueryBuilder()),
|
||||
count: jest.fn(),
|
||||
merge: jest.fn((entity: T, ...sources: Partial<T>[]) => Object.assign(entity, ...sources)),
|
||||
merge: jest.fn((entity: T, ...sources: Partial<T>[]) => Object.assign(entity as object, ...sources)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
571
src/modules/crm/activities.service.ts
Normal file
571
src/modules/crm/activities.service.ts
Normal file
@ -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<ActivityType, number>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<Activity>(
|
||||
`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<Activity> {
|
||||
const activity = await queryOne<Activity>(
|
||||
`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<Activity> {
|
||||
const activity = await queryOne<Activity>(
|
||||
`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<Activity> {
|
||||
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<Activity> {
|
||||
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<Activity> {
|
||||
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<void> {
|
||||
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<Activity[]> {
|
||||
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<Activity>(
|
||||
`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<ActivitySummary> {
|
||||
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<ActivityType, number> = {
|
||||
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<Activity> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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();
|
||||
452
src/modules/crm/forecasting.service.ts
Normal file
452
src/modules/crm/forecasting.service.ts
Normal file
@ -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<SalesForecast> {
|
||||
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<ForecastPeriod>(
|
||||
`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<WinLossAnalysis[]> {
|
||||
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<WinLossAnalysis>(
|
||||
`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<PipelineMetrics> {
|
||||
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();
|
||||
@ -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';
|
||||
|
||||
75
src/modules/financial/entities/account-mapping.entity.ts
Normal file
75
src/modules/financial/entities/account-mapping.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
711
src/modules/financial/gl-posting.service.ts
Normal file
711
src/modules/financial/gl-posting.service.ts
Normal file
@ -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<AccountMapping | null> {
|
||||
const mapping = await queryOne<AccountMapping>(
|
||||
`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<AccountMapping[]> {
|
||||
return query<AccountMapping>(
|
||||
`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<AccountMapping> {
|
||||
const result = await queryOne<AccountMapping>(
|
||||
`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<PostingResult> {
|
||||
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<PostingResult> {
|
||||
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<PostingResult> {
|
||||
// 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<JournalEntryLineInput & { id: string }>(
|
||||
`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();
|
||||
@ -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';
|
||||
|
||||
@ -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<Invoice> {
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
376
src/modules/inventory/reorder-alerts.service.ts
Normal file
376
src/modules/inventory/reorder-alerts.service.ts
Normal file
@ -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<ReorderAlert>(
|
||||
`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<StockLevelReport>(
|
||||
`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<StockSummary[]> {
|
||||
let whereClause = `WHERE sq.tenant_id = $1`;
|
||||
const params: any[] = [tenantId];
|
||||
|
||||
if (productId) {
|
||||
whereClause += ` AND sq.product_id = $2`;
|
||||
params.push(productId);
|
||||
}
|
||||
|
||||
return query<StockSummary>(
|
||||
`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<ReorderAlert | null> {
|
||||
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<ReorderAlert>(
|
||||
`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();
|
||||
@ -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';
|
||||
|
||||
473
src/modules/inventory/stock-reservation.service.ts
Normal file
473
src/modules/inventory/stock-reservation.service.ts
Normal file
@ -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<StockAvailability[]> {
|
||||
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<ReservationResult> {
|
||||
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<void> {
|
||||
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<ReservationResult> {
|
||||
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<void> {
|
||||
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();
|
||||
785
src/modules/projects/billing.service.ts
Normal file
785
src/modules/projects/billing.service.ts
Normal file
@ -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<BillingRate | null> {
|
||||
const targetDate = date || new Date();
|
||||
|
||||
// Try to find the most specific rate
|
||||
const rates = await query<BillingRate>(
|
||||
`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<BillingRate> {
|
||||
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<BillingRate>(
|
||||
`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<BillingRate> {
|
||||
const existing = await queryOne<BillingRate>(
|
||||
`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<BillingRate>(
|
||||
`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<BillingRate[]> {
|
||||
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<BillingRate>(
|
||||
`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<UnbilledTimesheet>(
|
||||
`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<TimesheetBillingSummary[]> {
|
||||
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<TimesheetBillingSummary>(
|
||||
`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<InvoiceFromTimesheetsResult> {
|
||||
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<string, typeof lineData[0]>();
|
||||
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<string, typeof lineData[0]>();
|
||||
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<string, typeof lineData[0]>();
|
||||
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();
|
||||
641
src/modules/projects/hr-integration.service.ts
Normal file
641
src/modules/projects/hr-integration.service.ts
Normal file
@ -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<EmployeeInfo | null> {
|
||||
const employee = await queryOne<EmployeeInfo>(
|
||||
`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<EmployeeInfo[]> {
|
||||
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<EmployeeInfo>(
|
||||
`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<EmployeeCostRate | null> {
|
||||
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<void> {
|
||||
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<ProjectProfitability> {
|
||||
// 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<string, { employee_id: string; employee_name: string; hourly_cost: number }>();
|
||||
|
||||
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<string, {
|
||||
employee_id: string;
|
||||
employee_name: string;
|
||||
hours: number;
|
||||
billable_hours: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}>();
|
||||
|
||||
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<EmployeeTimesheetSummary[]> {
|
||||
// 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();
|
||||
@ -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';
|
||||
|
||||
@ -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<PurchaseOrder> {
|
||||
@ -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();
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 }> {
|
||||
|
||||
@ -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<Quotation>,
|
||||
@ -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<Quotation> = { 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<SalesOrder> {
|
||||
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<SalesOrder> = { 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<SalesOrder> {
|
||||
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<SalesOrder> = {
|
||||
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<SalesOrder | null> {
|
||||
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<SalesOrder | null> {
|
||||
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<SalesOrder | null> {
|
||||
/**
|
||||
* @deprecated Use pickings validation flow for proper delivery tracking
|
||||
*/
|
||||
async shipOrder(id: string, tenantId: string, _trackingNumber?: string, _carrier?: string, userId?: string): Promise<SalesOrder | null> {
|
||||
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<SalesOrder | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user