[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:
rckrdmrd 2026-01-18 05:49:20 -06:00
parent 6054102774
commit edadaf3180
22 changed files with 5219 additions and 155 deletions

View File

@ -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)),
};
}

View 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();

View 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();

View File

@ -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';

View 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;
}

View File

@ -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';

View 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();

View File

@ -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';

View File

@ -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',

View File

@ -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();

View File

@ -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();

View 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();

View File

@ -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';

View 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();

View 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();

View 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();

View File

@ -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';

View File

@ -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();

View File

@ -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); }
}

View File

@ -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;
}

View File

@ -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 }> {

View File

@ -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);
}
}