erp-core-backend/src/modules/sales/orders.service.ts
rckrdmrd edadaf3180 [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>
2026-01-18 05:49:20 -06:00

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