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>
890 lines
28 KiB
TypeScript
890 lines
28 KiB
TypeScript
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;
|
|
order_id: string;
|
|
product_id: string;
|
|
product_name?: string;
|
|
description: string;
|
|
quantity: number;
|
|
qty_delivered: number;
|
|
qty_invoiced: number;
|
|
uom_id: string;
|
|
uom_name?: string;
|
|
price_unit: number;
|
|
discount: number;
|
|
tax_ids: string[];
|
|
amount_untaxed: number;
|
|
amount_tax: number;
|
|
amount_total: number;
|
|
analytic_account_id?: string;
|
|
}
|
|
|
|
export interface SalesOrder {
|
|
id: string;
|
|
tenant_id: string;
|
|
company_id: string;
|
|
company_name?: string;
|
|
name: string;
|
|
client_order_ref?: string;
|
|
partner_id: string;
|
|
partner_name?: string;
|
|
order_date: Date;
|
|
validity_date?: Date;
|
|
commitment_date?: Date;
|
|
currency_id: string;
|
|
currency_code?: string;
|
|
pricelist_id?: string;
|
|
pricelist_name?: string;
|
|
payment_term_id?: string;
|
|
user_id?: string;
|
|
user_name?: string;
|
|
sales_team_id?: string;
|
|
sales_team_name?: string;
|
|
amount_untaxed: number;
|
|
amount_tax: number;
|
|
amount_total: number;
|
|
status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled';
|
|
invoice_status: 'pending' | 'partial' | 'invoiced';
|
|
delivery_status: 'pending' | 'partial' | 'delivered';
|
|
invoice_policy: 'order' | 'delivery';
|
|
picking_id?: string;
|
|
notes?: string;
|
|
terms_conditions?: string;
|
|
lines?: SalesOrderLine[];
|
|
created_at: Date;
|
|
confirmed_at?: Date;
|
|
}
|
|
|
|
export interface CreateSalesOrderDto {
|
|
company_id: string;
|
|
partner_id: string;
|
|
client_order_ref?: string;
|
|
order_date?: string;
|
|
validity_date?: string;
|
|
commitment_date?: string;
|
|
currency_id: string;
|
|
pricelist_id?: string;
|
|
payment_term_id?: string;
|
|
sales_team_id?: string;
|
|
invoice_policy?: 'order' | 'delivery';
|
|
notes?: string;
|
|
terms_conditions?: string;
|
|
}
|
|
|
|
export interface UpdateSalesOrderDto {
|
|
partner_id?: string;
|
|
client_order_ref?: string | null;
|
|
order_date?: string;
|
|
validity_date?: string | null;
|
|
commitment_date?: string | null;
|
|
currency_id?: string;
|
|
pricelist_id?: string | null;
|
|
payment_term_id?: string | null;
|
|
sales_team_id?: string | null;
|
|
invoice_policy?: 'order' | 'delivery';
|
|
notes?: string | null;
|
|
terms_conditions?: string | null;
|
|
}
|
|
|
|
export interface CreateSalesOrderLineDto {
|
|
product_id: string;
|
|
description: string;
|
|
quantity: number;
|
|
uom_id: string;
|
|
price_unit: number;
|
|
discount?: number;
|
|
tax_ids?: string[];
|
|
analytic_account_id?: string;
|
|
}
|
|
|
|
export interface UpdateSalesOrderLineDto {
|
|
description?: string;
|
|
quantity?: number;
|
|
uom_id?: string;
|
|
price_unit?: number;
|
|
discount?: number;
|
|
tax_ids?: string[];
|
|
analytic_account_id?: string | null;
|
|
}
|
|
|
|
export interface SalesOrderFilters {
|
|
company_id?: string;
|
|
partner_id?: string;
|
|
status?: string;
|
|
invoice_status?: string;
|
|
delivery_status?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
search?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
class OrdersService {
|
|
async findAll(tenantId: string, filters: SalesOrderFilters = {}): Promise<{ data: SalesOrder[]; total: number }> {
|
|
const { company_id, partner_id, status, invoice_status, delivery_status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
|
const offset = (page - 1) * limit;
|
|
|
|
let whereClause = 'WHERE so.tenant_id = $1';
|
|
const params: any[] = [tenantId];
|
|
let paramIndex = 2;
|
|
|
|
if (company_id) {
|
|
whereClause += ` AND so.company_id = $${paramIndex++}`;
|
|
params.push(company_id);
|
|
}
|
|
|
|
if (partner_id) {
|
|
whereClause += ` AND so.partner_id = $${paramIndex++}`;
|
|
params.push(partner_id);
|
|
}
|
|
|
|
if (status) {
|
|
whereClause += ` AND so.status = $${paramIndex++}`;
|
|
params.push(status);
|
|
}
|
|
|
|
if (invoice_status) {
|
|
whereClause += ` AND so.invoice_status = $${paramIndex++}`;
|
|
params.push(invoice_status);
|
|
}
|
|
|
|
if (delivery_status) {
|
|
whereClause += ` AND so.delivery_status = $${paramIndex++}`;
|
|
params.push(delivery_status);
|
|
}
|
|
|
|
if (date_from) {
|
|
whereClause += ` AND so.order_date >= $${paramIndex++}`;
|
|
params.push(date_from);
|
|
}
|
|
|
|
if (date_to) {
|
|
whereClause += ` AND so.order_date <= $${paramIndex++}`;
|
|
params.push(date_to);
|
|
}
|
|
|
|
if (search) {
|
|
whereClause += ` AND (so.name ILIKE $${paramIndex} OR so.client_order_ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
|
params.push(`%${search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
const countResult = await queryOne<{ count: string }>(
|
|
`SELECT COUNT(*) as count
|
|
FROM sales.sales_orders so
|
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
|
${whereClause}`,
|
|
params
|
|
);
|
|
|
|
params.push(limit, offset);
|
|
const data = await query<SalesOrder>(
|
|
`SELECT so.*,
|
|
c.name as company_name,
|
|
p.name as partner_name,
|
|
cu.code as currency_code,
|
|
pl.name as pricelist_name,
|
|
u.name as user_name,
|
|
st.name as sales_team_name
|
|
FROM sales.sales_orders so
|
|
LEFT JOIN auth.companies c ON so.company_id = c.id
|
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
|
LEFT JOIN core.currencies cu ON so.currency_id = cu.id
|
|
LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id
|
|
LEFT JOIN auth.users u ON so.user_id = u.id
|
|
LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id
|
|
${whereClause}
|
|
ORDER BY so.created_at DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
params
|
|
);
|
|
|
|
return {
|
|
data,
|
|
total: parseInt(countResult?.count || '0', 10),
|
|
};
|
|
}
|
|
|
|
async findById(id: string, tenantId: string): Promise<SalesOrder> {
|
|
const order = await queryOne<SalesOrder>(
|
|
`SELECT so.*,
|
|
c.name as company_name,
|
|
p.name as partner_name,
|
|
cu.code as currency_code,
|
|
pl.name as pricelist_name,
|
|
u.name as user_name,
|
|
st.name as sales_team_name
|
|
FROM sales.sales_orders so
|
|
LEFT JOIN auth.companies c ON so.company_id = c.id
|
|
LEFT JOIN core.partners p ON so.partner_id = p.id
|
|
LEFT JOIN core.currencies cu ON so.currency_id = cu.id
|
|
LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id
|
|
LEFT JOIN auth.users u ON so.user_id = u.id
|
|
LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id
|
|
WHERE so.id = $1 AND so.tenant_id = $2`,
|
|
[id, tenantId]
|
|
);
|
|
|
|
if (!order) {
|
|
throw new NotFoundError('Orden de venta no encontrada');
|
|
}
|
|
|
|
// Get lines
|
|
const lines = await query<SalesOrderLine>(
|
|
`SELECT sol.*,
|
|
pr.name as product_name,
|
|
um.name as uom_name
|
|
FROM sales.sales_order_lines sol
|
|
LEFT JOIN inventory.products pr ON sol.product_id = pr.id
|
|
LEFT JOIN core.uom um ON sol.uom_id = um.id
|
|
WHERE sol.order_id = $1
|
|
ORDER BY sol.created_at`,
|
|
[id]
|
|
);
|
|
|
|
order.lines = lines;
|
|
|
|
return order;
|
|
}
|
|
|
|
async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
|
|
// Generate sequence number using atomic database function
|
|
const orderNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.SALES_ORDER, tenantId);
|
|
|
|
const orderDate = dto.order_date || new Date().toISOString().split('T')[0];
|
|
|
|
const order = await queryOne<SalesOrder>(
|
|
`INSERT INTO sales.sales_orders (
|
|
tenant_id, company_id, name, client_order_ref, partner_id, order_date,
|
|
validity_date, commitment_date, currency_id, pricelist_id, payment_term_id,
|
|
user_id, sales_team_id, invoice_policy, notes, terms_conditions, created_by
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
|
RETURNING *`,
|
|
[
|
|
tenantId, dto.company_id, orderNumber, dto.client_order_ref, dto.partner_id,
|
|
orderDate, dto.validity_date, dto.commitment_date, dto.currency_id,
|
|
dto.pricelist_id, dto.payment_term_id, userId, dto.sales_team_id,
|
|
dto.invoice_policy || 'order', dto.notes, dto.terms_conditions, userId
|
|
]
|
|
);
|
|
|
|
return order!;
|
|
}
|
|
|
|
async update(id: string, dto: UpdateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
if (existing.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden editar órdenes en estado borrador');
|
|
}
|
|
|
|
const updateFields: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (dto.partner_id !== undefined) {
|
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
|
values.push(dto.partner_id);
|
|
}
|
|
if (dto.client_order_ref !== undefined) {
|
|
updateFields.push(`client_order_ref = $${paramIndex++}`);
|
|
values.push(dto.client_order_ref);
|
|
}
|
|
if (dto.order_date !== undefined) {
|
|
updateFields.push(`order_date = $${paramIndex++}`);
|
|
values.push(dto.order_date);
|
|
}
|
|
if (dto.validity_date !== undefined) {
|
|
updateFields.push(`validity_date = $${paramIndex++}`);
|
|
values.push(dto.validity_date);
|
|
}
|
|
if (dto.commitment_date !== undefined) {
|
|
updateFields.push(`commitment_date = $${paramIndex++}`);
|
|
values.push(dto.commitment_date);
|
|
}
|
|
if (dto.currency_id !== undefined) {
|
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
|
values.push(dto.currency_id);
|
|
}
|
|
if (dto.pricelist_id !== undefined) {
|
|
updateFields.push(`pricelist_id = $${paramIndex++}`);
|
|
values.push(dto.pricelist_id);
|
|
}
|
|
if (dto.payment_term_id !== undefined) {
|
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
|
values.push(dto.payment_term_id);
|
|
}
|
|
if (dto.sales_team_id !== undefined) {
|
|
updateFields.push(`sales_team_id = $${paramIndex++}`);
|
|
values.push(dto.sales_team_id);
|
|
}
|
|
if (dto.invoice_policy !== undefined) {
|
|
updateFields.push(`invoice_policy = $${paramIndex++}`);
|
|
values.push(dto.invoice_policy);
|
|
}
|
|
if (dto.notes !== undefined) {
|
|
updateFields.push(`notes = $${paramIndex++}`);
|
|
values.push(dto.notes);
|
|
}
|
|
if (dto.terms_conditions !== undefined) {
|
|
updateFields.push(`terms_conditions = $${paramIndex++}`);
|
|
values.push(dto.terms_conditions);
|
|
}
|
|
|
|
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 sales.sales_orders SET ${updateFields.join(', ')}
|
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
|
values
|
|
);
|
|
|
|
return this.findById(id, tenantId);
|
|
}
|
|
|
|
async delete(id: string, tenantId: string): Promise<void> {
|
|
const existing = await this.findById(id, tenantId);
|
|
|
|
if (existing.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden eliminar órdenes en estado borrador');
|
|
}
|
|
|
|
await query(
|
|
`DELETE FROM sales.sales_orders WHERE id = $1 AND tenant_id = $2`,
|
|
[id, tenantId]
|
|
);
|
|
}
|
|
|
|
async addLine(orderId: string, dto: CreateSalesOrderLineDto, tenantId: string, userId: string): Promise<SalesOrderLine> {
|
|
const order = await this.findById(orderId, tenantId);
|
|
|
|
if (order.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden agregar líneas a órdenes en estado borrador');
|
|
}
|
|
|
|
// Calculate amounts with taxes using taxesService
|
|
const taxResult = await taxesService.calculateTaxes(
|
|
{
|
|
quantity: dto.quantity,
|
|
priceUnit: dto.price_unit,
|
|
discount: dto.discount || 0,
|
|
taxIds: dto.tax_ids || [],
|
|
},
|
|
tenantId,
|
|
'sales'
|
|
);
|
|
const amountUntaxed = taxResult.amountUntaxed;
|
|
const amountTax = taxResult.amountTax;
|
|
const amountTotal = taxResult.amountTotal;
|
|
|
|
const line = await queryOne<SalesOrderLine>(
|
|
`INSERT INTO sales.sales_order_lines (
|
|
order_id, tenant_id, product_id, description, quantity, uom_id,
|
|
price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total, analytic_account_id
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
RETURNING *`,
|
|
[
|
|
orderId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
|
dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.analytic_account_id
|
|
]
|
|
);
|
|
|
|
// Update order totals
|
|
await this.updateTotals(orderId);
|
|
|
|
return line!;
|
|
}
|
|
|
|
async updateLine(orderId: string, lineId: string, dto: UpdateSalesOrderLineDto, tenantId: string): Promise<SalesOrderLine> {
|
|
const order = await this.findById(orderId, tenantId);
|
|
|
|
if (order.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden editar líneas de órdenes en estado borrador');
|
|
}
|
|
|
|
const existingLine = order.lines?.find(l => l.id === lineId);
|
|
if (!existingLine) {
|
|
throw new NotFoundError('Línea de orden no encontrada');
|
|
}
|
|
|
|
const updateFields: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
const quantity = dto.quantity ?? existingLine.quantity;
|
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
|
const discount = dto.discount ?? existingLine.discount;
|
|
|
|
if (dto.description !== undefined) {
|
|
updateFields.push(`description = $${paramIndex++}`);
|
|
values.push(dto.description);
|
|
}
|
|
if (dto.quantity !== undefined) {
|
|
updateFields.push(`quantity = $${paramIndex++}`);
|
|
values.push(dto.quantity);
|
|
}
|
|
if (dto.uom_id !== undefined) {
|
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
|
values.push(dto.uom_id);
|
|
}
|
|
if (dto.price_unit !== undefined) {
|
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
|
values.push(dto.price_unit);
|
|
}
|
|
if (dto.discount !== undefined) {
|
|
updateFields.push(`discount = $${paramIndex++}`);
|
|
values.push(dto.discount);
|
|
}
|
|
if (dto.tax_ids !== undefined) {
|
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
|
values.push(dto.tax_ids);
|
|
}
|
|
if (dto.analytic_account_id !== undefined) {
|
|
updateFields.push(`analytic_account_id = $${paramIndex++}`);
|
|
values.push(dto.analytic_account_id);
|
|
}
|
|
|
|
// Recalculate amounts
|
|
const subtotal = quantity * priceUnit;
|
|
const discountAmount = subtotal * discount / 100;
|
|
const amountUntaxed = subtotal - discountAmount;
|
|
const amountTax = 0; // TODO: Calculate taxes
|
|
const amountTotal = amountUntaxed + amountTax;
|
|
|
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
|
values.push(amountUntaxed);
|
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
|
values.push(amountTax);
|
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
|
values.push(amountTotal);
|
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
|
|
|
values.push(lineId, orderId);
|
|
|
|
await query(
|
|
`UPDATE sales.sales_order_lines SET ${updateFields.join(', ')}
|
|
WHERE id = $${paramIndex++} AND order_id = $${paramIndex}`,
|
|
values
|
|
);
|
|
|
|
// Update order totals
|
|
await this.updateTotals(orderId);
|
|
|
|
const updated = await queryOne<SalesOrderLine>(
|
|
`SELECT * FROM sales.sales_order_lines WHERE id = $1`,
|
|
[lineId]
|
|
);
|
|
|
|
return updated!;
|
|
}
|
|
|
|
async removeLine(orderId: string, lineId: string, tenantId: string): Promise<void> {
|
|
const order = await this.findById(orderId, tenantId);
|
|
|
|
if (order.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden eliminar líneas de órdenes en estado borrador');
|
|
}
|
|
|
|
await query(
|
|
`DELETE FROM sales.sales_order_lines WHERE id = $1 AND order_id = $2`,
|
|
[lineId, orderId]
|
|
);
|
|
|
|
// Update order totals
|
|
await this.updateTotals(orderId);
|
|
}
|
|
|
|
async confirm(id: string, tenantId: string, userId: string): Promise<SalesOrder> {
|
|
const order = await this.findById(id, tenantId);
|
|
|
|
if (order.status !== 'draft') {
|
|
throw new ValidationError('Solo se pueden confirmar órdenes en estado borrador');
|
|
}
|
|
|
|
if (!order.lines || order.lines.length === 0) {
|
|
throw new ValidationError('La orden debe tener al menos una línea');
|
|
}
|
|
|
|
const client = await getClient();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 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 = $2,
|
|
updated_by = $2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $3`,
|
|
[pickingId, userId, id]
|
|
);
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
async cancel(id: string, tenantId: string, userId: string): Promise<SalesOrder> {
|
|
const order = await this.findById(id, tenantId);
|
|
|
|
if (order.status === 'done') {
|
|
throw new ValidationError('No se pueden cancelar órdenes completadas');
|
|
}
|
|
|
|
if (order.status === 'cancelled') {
|
|
throw new ValidationError('La orden ya está cancelada');
|
|
}
|
|
|
|
// Check if there are any deliveries or invoices
|
|
if (order.delivery_status !== 'pending') {
|
|
throw new ValidationError('No se puede cancelar: ya hay entregas asociadas');
|
|
}
|
|
|
|
if (order.invoice_status !== 'pending') {
|
|
throw new ValidationError('No se puede cancelar: ya hay facturas asociadas');
|
|
}
|
|
|
|
const client = await getClient();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 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 }> {
|
|
const order = await this.findById(id, tenantId);
|
|
|
|
if (order.status !== 'sent' && order.status !== 'sale' && order.status !== 'done') {
|
|
throw new ValidationError('Solo se pueden facturar órdenes confirmadas (sent/sale)');
|
|
}
|
|
|
|
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 => {
|
|
if (order.invoice_policy === 'order') {
|
|
return l.quantity > l.qty_invoiced;
|
|
} else {
|
|
return l.qty_delivered > l.qty_invoiced;
|
|
}
|
|
});
|
|
|
|
if (!linesToInvoice || linesToInvoice.length === 0) {
|
|
throw new ValidationError('No hay líneas para facturar');
|
|
}
|
|
|
|
const client = await getClient();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Generate invoice number
|
|
const seqResult = await client.query(
|
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
|
|
FROM financial.invoices WHERE tenant_id = $1 AND name LIKE 'INV-%'`,
|
|
[tenantId]
|
|
);
|
|
const invoiceNumber = `INV-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`;
|
|
|
|
// Create invoice
|
|
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, CURRENT_DATE + INTERVAL '30 days',
|
|
$5, 'customer', 0, 0, 0, $6, $7)
|
|
RETURNING id`,
|
|
[tenantId, order.company_id, invoiceNumber, order.partner_id, 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 = order.invoice_policy === 'order'
|
|
? line.quantity - line.qty_invoiced
|
|
: line.qty_delivered - line.qty_invoiced;
|
|
|
|
const lineAmount = qtyToInvoice * line.price_unit * (1 - line.discount / 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, lineAmount]
|
|
);
|
|
|
|
await client.query(
|
|
`UPDATE sales.sales_order_lines SET qty_invoiced = qty_invoiced + $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]
|
|
);
|
|
|
|
// Update order invoice_status
|
|
await client.query(
|
|
`UPDATE sales.sales_orders SET
|
|
invoice_status = CASE
|
|
WHEN (SELECT SUM(qty_invoiced) FROM sales.sales_order_lines WHERE order_id = $1) >=
|
|
(SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1)
|
|
THEN 'invoiced'::sales.invoice_status
|
|
ELSE 'partial'::sales.invoice_status
|
|
END,
|
|
updated_by = $2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1`,
|
|
[id, userId]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
return { orderId: id, invoiceId };
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
private async updateTotals(orderId: string): Promise<void> {
|
|
await query(
|
|
`UPDATE sales.sales_orders SET
|
|
amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.sales_order_lines WHERE order_id = $1), 0),
|
|
amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.sales_order_lines WHERE order_id = $1), 0),
|
|
amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.sales_order_lines WHERE order_id = $1), 0)
|
|
WHERE id = $1`,
|
|
[orderId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export const ordersService = new OrdersService();
|