diff --git a/src/modules/financial/dto/create-bank-statement.dto.ts b/src/modules/financial/dto/create-bank-statement.dto.ts new file mode 100644 index 0000000..ba8b838 --- /dev/null +++ b/src/modules/financial/dto/create-bank-statement.dto.ts @@ -0,0 +1,145 @@ +/** + * DTO para crear un extracto bancario con sus lineas + */ +export interface CreateBankStatementLineDto { + /** Fecha de la transaccion (YYYY-MM-DD) */ + transaction_date: string; + /** Fecha valor opcional (YYYY-MM-DD) */ + value_date?: string; + /** Descripcion del movimiento */ + description?: string; + /** Referencia del movimiento (numero de cheque, transferencia, etc.) */ + reference?: string; + /** Monto del movimiento (positivo=deposito, negativo=retiro) */ + amount: number; + /** ID del partner si se conoce */ + partner_id?: string; + /** Notas adicionales */ + notes?: string; +} + +/** + * DTO para crear un extracto bancario completo + */ +export interface CreateBankStatementDto { + /** ID de la compania */ + company_id?: string; + /** ID de la cuenta bancaria (cuenta contable tipo banco) */ + bank_account_id?: string; + /** Fecha del extracto (YYYY-MM-DD) */ + statement_date: string; + /** Saldo de apertura */ + opening_balance: number; + /** Saldo de cierre */ + closing_balance: number; + /** Lineas del extracto */ + lines: CreateBankStatementLineDto[]; +} + +/** + * DTO para actualizar un extracto bancario + */ +export interface UpdateBankStatementDto { + /** ID de la cuenta bancaria */ + bank_account_id?: string; + /** Fecha del extracto */ + statement_date?: string; + /** Saldo de apertura */ + opening_balance?: number; + /** Saldo de cierre */ + closing_balance?: number; +} + +/** + * DTO para agregar lineas a un extracto existente + */ +export interface AddBankStatementLinesDto { + /** Lineas a agregar */ + lines: CreateBankStatementLineDto[]; +} + +/** + * Filtros para buscar extractos bancarios + */ +export interface BankStatementFilters { + /** ID de la compania */ + company_id?: string; + /** ID de la cuenta bancaria */ + bank_account_id?: string; + /** Estado del extracto */ + status?: 'draft' | 'reconciling' | 'reconciled'; + /** Fecha desde (YYYY-MM-DD) */ + date_from?: string; + /** Fecha hasta (YYYY-MM-DD) */ + date_to?: string; + /** Pagina actual */ + page?: number; + /** Limite de resultados */ + limit?: number; +} + +/** + * Respuesta con extracto y sus lineas + */ +export interface BankStatementWithLines { + id: string; + tenant_id: string; + company_id: string | null; + bank_account_id: string | null; + bank_account_name?: string; + statement_date: Date; + opening_balance: number; + closing_balance: number; + calculated_balance?: number; + status: 'draft' | 'reconciling' | 'reconciled'; + imported_at: Date | null; + imported_by: string | null; + reconciled_at: Date | null; + reconciled_by: string | null; + created_at: Date; + reconciliation_progress?: number; + lines: BankStatementLineResponse[]; +} + +/** + * Respuesta de linea de extracto + */ +export interface BankStatementLineResponse { + id: string; + statement_id: string; + transaction_date: Date; + value_date: Date | null; + description: string | null; + reference: string | null; + amount: number; + is_reconciled: boolean; + reconciled_entry_id: string | null; + reconciled_at: Date | null; + reconciled_by: string | null; + partner_id: string | null; + partner_name?: string; + notes: string | null; + created_at: Date; + /** Posibles matches encontrados por auto-reconcile */ + suggested_matches?: SuggestedMatch[]; +} + +/** + * Match sugerido para una linea + */ +export interface SuggestedMatch { + /** ID de la linea de asiento */ + entry_line_id: string; + /** ID del asiento */ + entry_id: string; + /** Referencia del asiento */ + entry_ref: string | null; + /** Fecha del asiento */ + entry_date: Date; + /** Monto de la linea */ + amount: number; + /** Tipo de match */ + match_type: 'exact_amount' | 'amount_date' | 'reference' | 'partner'; + /** Confianza del match (0-100) */ + confidence: number; +} diff --git a/src/modules/financial/dto/index.ts b/src/modules/financial/dto/index.ts new file mode 100644 index 0000000..dc2d26a --- /dev/null +++ b/src/modules/financial/dto/index.ts @@ -0,0 +1,6 @@ +/** + * DTOs para el modulo de conciliacion bancaria + */ + +export * from './create-bank-statement.dto.js'; +export * from './reconcile-line.dto.js'; diff --git a/src/modules/financial/dto/reconcile-line.dto.ts b/src/modules/financial/dto/reconcile-line.dto.ts new file mode 100644 index 0000000..c1035d3 --- /dev/null +++ b/src/modules/financial/dto/reconcile-line.dto.ts @@ -0,0 +1,171 @@ +/** + * DTO para conciliar una linea de extracto con una linea de asiento + */ +export interface ReconcileLineDto { + /** ID de la linea de asiento contable a conciliar */ + entry_line_id: string; +} + +/** + * DTO para conciliar multiples lineas en batch + */ +export interface BatchReconcileDto { + /** Array de pares linea-extracto con linea-asiento */ + reconciliations: { + /** ID de la linea de extracto */ + statement_line_id: string; + /** ID de la linea de asiento */ + entry_line_id: string; + }[]; +} + +/** + * DTO para crear un asiento y conciliar automaticamente + * Util cuando no existe asiento previo + */ +export interface CreateAndReconcileDto { + /** ID de la cuenta contable destino */ + account_id: string; + /** ID del diario a usar */ + journal_id: string; + /** ID del partner (opcional) */ + partner_id?: string; + /** Referencia para el asiento */ + ref?: string; + /** Notas adicionales */ + notes?: string; +} + +/** + * Resultado de operacion de conciliacion + */ +export interface ReconcileResult { + /** Exito de la operacion */ + success: boolean; + /** ID de la linea de extracto */ + statement_line_id: string; + /** ID de la linea de asiento conciliada */ + entry_line_id: string | null; + /** Mensaje de error si fallo */ + error?: string; +} + +/** + * Resultado de auto-reconciliacion + */ +export interface AutoReconcileResult { + /** Total de lineas procesadas */ + total_lines: number; + /** Lineas conciliadas automaticamente */ + reconciled_count: number; + /** Lineas que no pudieron conciliarse */ + unreconciled_count: number; + /** Detalle de conciliaciones realizadas */ + reconciled_lines: { + statement_line_id: string; + entry_line_id: string; + match_type: string; + confidence: number; + }[]; + /** Lineas con sugerencias pero sin match automatico */ + lines_with_suggestions: { + statement_line_id: string; + suggestions: number; + }[]; +} + +/** + * DTO para buscar lineas de asiento candidatas a conciliar + */ +export interface FindMatchCandidatesDto { + /** Monto a buscar */ + amount: number; + /** Fecha aproximada */ + date?: string; + /** Referencia a buscar */ + reference?: string; + /** ID del partner */ + partner_id?: string; + /** Tolerancia de monto (porcentaje, ej: 0.01 = 1%) */ + amount_tolerance?: number; + /** Tolerancia de dias */ + date_tolerance_days?: number; + /** Limite de resultados */ + limit?: number; +} + +/** + * Respuesta de busqueda de candidatos + */ +export interface MatchCandidate { + /** ID de la linea de asiento */ + id: string; + /** ID del asiento */ + entry_id: string; + /** Nombre/numero del asiento */ + entry_name: string; + /** Referencia del asiento */ + entry_ref: string | null; + /** Fecha del asiento */ + entry_date: Date; + /** ID de la cuenta */ + account_id: string; + /** Codigo de la cuenta */ + account_code: string; + /** Nombre de la cuenta */ + account_name: string; + /** Monto al debe */ + debit: number; + /** Monto al haber */ + credit: number; + /** Monto neto (debit - credit) */ + net_amount: number; + /** Descripcion de la linea */ + description: string | null; + /** ID del partner */ + partner_id: string | null; + /** Nombre del partner */ + partner_name: string | null; + /** Tipo de match */ + match_type: 'exact_amount' | 'amount_date' | 'reference' | 'partner' | 'rule'; + /** Confianza del match */ + confidence: number; +} + +/** + * DTO para crear/actualizar regla de conciliacion + */ +export interface CreateReconciliationRuleDto { + /** Nombre de la regla */ + name: string; + /** ID de la compania (opcional) */ + company_id?: string; + /** Tipo de match */ + match_type: 'exact_amount' | 'reference_contains' | 'partner_name'; + /** Valor a matchear */ + match_value: string; + /** Cuenta destino para auto-crear asiento */ + auto_account_id?: string; + /** Prioridad (mayor = primero) */ + priority?: number; + /** Activa o no */ + is_active?: boolean; +} + +/** + * DTO para actualizar regla de conciliacion + */ +export interface UpdateReconciliationRuleDto { + /** Nombre de la regla */ + name?: string; + /** Tipo de match */ + match_type?: 'exact_amount' | 'reference_contains' | 'partner_name'; + /** Valor a matchear */ + match_value?: string; + /** Cuenta destino */ + auto_account_id?: string | null; + /** Prioridad */ + priority?: number; + /** Activa o no */ + is_active?: boolean; +} diff --git a/src/modules/financial/entities/bank-reconciliation-rule.entity.ts b/src/modules/financial/entities/bank-reconciliation-rule.entity.ts new file mode 100644 index 0000000..d5b32eb --- /dev/null +++ b/src/modules/financial/entities/bank-reconciliation-rule.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Account } from './account.entity.js'; + +/** + * Tipo de regla de match para conciliacion automatica + */ +export type ReconciliationMatchType = 'exact_amount' | 'reference_contains' | 'partner_name'; + +/** + * Entity: BankReconciliationRule + * Reglas para conciliacion automatica de movimientos bancarios + * Schema: financial + * Table: bank_reconciliation_rules + */ +@Entity({ schema: 'financial', name: 'bank_reconciliation_rules' }) +@Index('idx_bank_reconciliation_rules_tenant_id', ['tenantId']) +@Index('idx_bank_reconciliation_rules_company_id', ['companyId']) +@Index('idx_bank_reconciliation_rules_is_active', ['isActive']) +@Index('idx_bank_reconciliation_rules_match_type', ['matchType']) +@Index('idx_bank_reconciliation_rules_priority', ['priority']) +export class BankReconciliationRule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'company_id' }) + companyId: string | null; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ + type: 'varchar', + length: 50, + nullable: false, + name: 'match_type', + }) + matchType: ReconciliationMatchType; + + @Column({ type: 'varchar', length: 255, nullable: false, name: 'match_value' }) + matchValue: string; + + @Column({ type: 'uuid', nullable: true, name: 'auto_account_id' }) + autoAccountId: string | null; + + @Column({ + type: 'boolean', + default: true, + nullable: false, + name: 'is_active', + }) + isActive: boolean; + + @Column({ + type: 'integer', + default: 0, + nullable: false, + }) + priority: number; + + // Relations + @ManyToOne(() => Account, { nullable: true }) + @JoinColumn({ name: 'auto_account_id' }) + autoAccount: Account | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp with time zone', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/financial/entities/bank-statement-line.entity.ts b/src/modules/financial/entities/bank-statement-line.entity.ts new file mode 100644 index 0000000..519bd27 --- /dev/null +++ b/src/modules/financial/entities/bank-statement-line.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { BankStatement } from './bank-statement.entity.js'; +import { JournalEntryLine } from './journal-entry-line.entity.js'; + +/** + * Entity: BankStatementLine + * Lineas/movimientos del extracto bancario + * Schema: financial + * Table: bank_statement_lines + */ +@Entity({ schema: 'financial', name: 'bank_statement_lines' }) +@Index('idx_bank_statement_lines_statement_id', ['statementId']) +@Index('idx_bank_statement_lines_tenant_id', ['tenantId']) +@Index('idx_bank_statement_lines_transaction_date', ['transactionDate']) +@Index('idx_bank_statement_lines_is_reconciled', ['isReconciled']) +@Index('idx_bank_statement_lines_reconciled_entry_id', ['reconciledEntryId']) +@Index('idx_bank_statement_lines_partner_id', ['partnerId']) +export class BankStatementLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'statement_id' }) + statementId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'date', nullable: false, name: 'transaction_date' }) + transactionDate: Date; + + @Column({ type: 'date', nullable: true, name: 'value_date' }) + valueDate: Date | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + reference: string | null; + + @Column({ + type: 'decimal', + precision: 15, + scale: 2, + nullable: false, + }) + amount: number; + + @Column({ + type: 'boolean', + default: false, + nullable: false, + name: 'is_reconciled', + }) + isReconciled: boolean; + + @Column({ type: 'uuid', nullable: true, name: 'reconciled_entry_id' }) + reconciledEntryId: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true, name: 'reconciled_at' }) + reconciledAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'reconciled_by' }) + reconciledBy: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) + partnerId: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => BankStatement, (statement) => statement.lines, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'statement_id' }) + statement: BankStatement; + + @ManyToOne(() => JournalEntryLine, { nullable: true }) + @JoinColumn({ name: 'reconciled_entry_id' }) + reconciledEntry: JournalEntryLine | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt: Date; +} diff --git a/src/modules/financial/entities/bank-statement.entity.ts b/src/modules/financial/entities/bank-statement.entity.ts new file mode 100644 index 0000000..17c7448 --- /dev/null +++ b/src/modules/financial/entities/bank-statement.entity.ts @@ -0,0 +1,111 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Account } from './account.entity.js'; + +/** + * Estado del extracto bancario + */ +export type BankStatementStatus = 'draft' | 'reconciling' | 'reconciled'; + +/** + * Entity: BankStatement + * Extractos bancarios importados para conciliacion + * Schema: financial + * Table: bank_statements + */ +@Entity({ schema: 'financial', name: 'bank_statements' }) +@Index('idx_bank_statements_tenant_id', ['tenantId']) +@Index('idx_bank_statements_company_id', ['companyId']) +@Index('idx_bank_statements_bank_account_id', ['bankAccountId']) +@Index('idx_bank_statements_statement_date', ['statementDate']) +@Index('idx_bank_statements_status', ['status']) +export class BankStatement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'company_id' }) + companyId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'bank_account_id' }) + bankAccountId: string | null; + + @Column({ type: 'date', nullable: false, name: 'statement_date' }) + statementDate: Date; + + @Column({ + type: 'decimal', + precision: 15, + scale: 2, + default: 0, + nullable: false, + name: 'opening_balance', + }) + openingBalance: number; + + @Column({ + type: 'decimal', + precision: 15, + scale: 2, + default: 0, + nullable: false, + name: 'closing_balance', + }) + closingBalance: number; + + @Column({ + type: 'varchar', + length: 20, + default: 'draft', + nullable: false, + }) + status: BankStatementStatus; + + @Column({ type: 'timestamp with time zone', nullable: true, name: 'imported_at' }) + importedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'imported_by' }) + importedBy: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true, name: 'reconciled_at' }) + reconciledAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'reconciled_by' }) + reconciledBy: string | null; + + // Relations + @ManyToOne(() => Account) + @JoinColumn({ name: 'bank_account_id' }) + bankAccount: Account | null; + + @OneToMany('BankStatementLine', 'statement') + lines: import('./bank-statement-line.entity.js').BankStatementLine[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp with time zone', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/financial/services/bank-reconciliation.service.ts b/src/modules/financial/services/bank-reconciliation.service.ts new file mode 100644 index 0000000..f4c6c95 --- /dev/null +++ b/src/modules/financial/services/bank-reconciliation.service.ts @@ -0,0 +1,810 @@ +import { query, queryOne, getClient } from '../../../config/database.js'; +import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; +import { + CreateBankStatementDto, + BankStatementFilters, + BankStatementWithLines, + BankStatementLineResponse, + SuggestedMatch, +} from '../dto/create-bank-statement.dto.js'; +import { + ReconcileResult, + AutoReconcileResult, + MatchCandidate, + FindMatchCandidatesDto, + CreateReconciliationRuleDto, + UpdateReconciliationRuleDto, +} from '../dto/reconcile-line.dto.js'; + +/** + * Representacion de extracto bancario + */ +export interface BankStatement { + id: string; + tenant_id: string; + company_id: string | null; + bank_account_id: string | null; + bank_account_name?: string; + statement_date: Date; + opening_balance: number; + closing_balance: number; + status: 'draft' | 'reconciling' | 'reconciled'; + imported_at: Date | null; + imported_by: string | null; + reconciled_at: Date | null; + reconciled_by: string | null; + created_at: Date; +} + +/** + * Representacion de regla de conciliacion + */ +export interface ReconciliationRule { + id: string; + tenant_id: string; + company_id: string | null; + name: string; + match_type: 'exact_amount' | 'reference_contains' | 'partner_name'; + match_value: string; + auto_account_id: string | null; + auto_account_name?: string; + is_active: boolean; + priority: number; + created_at: Date; +} + +/** + * Servicio para conciliacion bancaria + */ +class BankReconciliationService { + // ========================================== + // EXTRACTOS BANCARIOS + // ========================================== + + /** + * Importar un extracto bancario con sus lineas + */ + async importStatement( + dto: CreateBankStatementDto, + tenantId: string, + userId: string + ): Promise { + // Validaciones + if (!dto.lines || dto.lines.length === 0) { + throw new ValidationError('El extracto debe tener al menos una linea'); + } + + // Validar que el balance cuadre + const calculatedClosing = dto.opening_balance + dto.lines.reduce((sum, line) => sum + line.amount, 0); + if (Math.abs(calculatedClosing - dto.closing_balance) > 0.01) { + throw new ValidationError( + `El balance no cuadra. Apertura (${dto.opening_balance}) + Movimientos (${dto.lines.reduce((s, l) => s + l.amount, 0)}) = ${calculatedClosing}, pero cierre declarado es ${dto.closing_balance}` + ); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Crear el extracto + const statementResult = await client.query( + `INSERT INTO financial.bank_statements ( + tenant_id, company_id, bank_account_id, statement_date, + opening_balance, closing_balance, status, + imported_at, imported_by, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, 'draft', CURRENT_TIMESTAMP, $7, $7) + RETURNING *`, + [ + tenantId, + dto.company_id || null, + dto.bank_account_id || null, + dto.statement_date, + dto.opening_balance, + dto.closing_balance, + userId, + ] + ); + + const statement = statementResult.rows[0]; + + // Insertar las lineas + for (const line of dto.lines) { + await client.query( + `INSERT INTO financial.bank_statement_lines ( + statement_id, tenant_id, transaction_date, value_date, + description, reference, amount, partner_id, notes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + statement.id, + tenantId, + line.transaction_date, + line.value_date || null, + line.description || null, + line.reference || null, + line.amount, + line.partner_id || null, + line.notes || null, + ] + ); + } + + await client.query('COMMIT'); + return statement; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Obtener lista de extractos con filtros + */ + async findAll( + tenantId: string, + filters: BankStatementFilters = {} + ): Promise<{ data: BankStatement[]; total: number }> { + const { company_id, bank_account_id, status, date_from, date_to, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE bs.tenant_id = $1'; + const params: unknown[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND bs.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (bank_account_id) { + whereClause += ` AND bs.bank_account_id = $${paramIndex++}`; + params.push(bank_account_id); + } + + if (status) { + whereClause += ` AND bs.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND bs.statement_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND bs.statement_date <= $${paramIndex++}`; + params.push(date_to); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.bank_statements bs ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT bs.*, + a.name as bank_account_name, + financial.get_reconciliation_progress(bs.id) as reconciliation_progress + FROM financial.bank_statements bs + LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id + ${whereClause} + ORDER BY bs.statement_date DESC, bs.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + /** + * Obtener un extracto con todas sus lineas + */ + async getStatementWithLines(id: string, tenantId: string): Promise { + const statement = await queryOne( + `SELECT bs.*, + a.name as bank_account_name, + financial.get_reconciliation_progress(bs.id) as reconciliation_progress + FROM financial.bank_statements bs + LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id + WHERE bs.id = $1 AND bs.tenant_id = $2`, + [id, tenantId] + ); + + if (!statement) { + throw new NotFoundError('Extracto bancario no encontrado'); + } + + // Obtener lineas + const lines = await query( + `SELECT bsl.*, + p.name as partner_name + FROM financial.bank_statement_lines bsl + LEFT JOIN core.partners p ON bsl.partner_id = p.id + WHERE bsl.statement_id = $1 + ORDER BY bsl.transaction_date, bsl.created_at`, + [id] + ); + + // Calcular balance calculado + const calculatedBalance = + Number(statement.opening_balance) + lines.reduce((sum, line) => sum + Number(line.amount), 0); + + statement.lines = lines; + statement.calculated_balance = calculatedBalance; + + return statement; + } + + /** + * Eliminar un extracto (solo en estado draft) + */ + async deleteStatement(id: string, tenantId: string): Promise { + const statement = await queryOne( + `SELECT * FROM financial.bank_statements WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!statement) { + throw new NotFoundError('Extracto bancario no encontrado'); + } + + if (statement.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar extractos en estado borrador'); + } + + await query(`DELETE FROM financial.bank_statements WHERE id = $1`, [id]); + } + + // ========================================== + // CONCILIACION + // ========================================== + + /** + * Ejecutar auto-conciliacion de un extracto + * Busca matches automaticos por monto, fecha y referencia + */ + async autoReconcile(statementId: string, tenantId: string, userId: string): Promise { + const statement = await this.getStatementWithLines(statementId, tenantId); + + if (statement.status === 'reconciled') { + throw new ValidationError('El extracto ya esta completamente conciliado'); + } + + // Cambiar estado a reconciling si esta en draft + if (statement.status === 'draft') { + await query( + `UPDATE financial.bank_statements SET status = 'reconciling', updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [userId, statementId] + ); + } + + const result: AutoReconcileResult = { + total_lines: statement.lines.length, + reconciled_count: 0, + unreconciled_count: 0, + reconciled_lines: [], + lines_with_suggestions: [], + }; + + // Obtener reglas activas + const rules = await query( + `SELECT * FROM financial.bank_reconciliation_rules + WHERE tenant_id = $1 AND is_active = true + ORDER BY priority DESC`, + [tenantId] + ); + + // Procesar cada linea no conciliada + for (const line of statement.lines) { + if (line.is_reconciled) { + continue; + } + + // Buscar candidatos a match + const candidates = await this.findMatchCandidates( + { + amount: Math.abs(Number(line.amount)), + date: line.transaction_date.toString(), + reference: line.reference || undefined, + partner_id: line.partner_id || undefined, + amount_tolerance: 0, + date_tolerance_days: 3, + limit: 5, + }, + tenantId, + statement.bank_account_id || undefined + ); + + // Aplicar reglas personalizadas + for (const rule of rules) { + const ruleMatch = this.applyRule(rule, line); + if (ruleMatch) { + // Si la regla tiene cuenta auto, se podria crear asiento automatico + // Por ahora solo marcamos como sugerencia + } + } + + // Si hay un match con confianza >= 90%, conciliar automaticamente + const exactMatch = candidates.find((c) => c.confidence >= 90); + if (exactMatch) { + try { + await this.reconcileLine(line.id, exactMatch.id, tenantId, userId); + result.reconciled_count++; + result.reconciled_lines.push({ + statement_line_id: line.id, + entry_line_id: exactMatch.id, + match_type: exactMatch.match_type, + confidence: exactMatch.confidence, + }); + } catch { + // Si falla, agregar a sugerencias + result.lines_with_suggestions.push({ + statement_line_id: line.id, + suggestions: candidates.length, + }); + result.unreconciled_count++; + } + } else if (candidates.length > 0) { + result.lines_with_suggestions.push({ + statement_line_id: line.id, + suggestions: candidates.length, + }); + result.unreconciled_count++; + } else { + result.unreconciled_count++; + } + } + + return result; + } + + /** + * Buscar lineas de asiento candidatas a conciliar + */ + async findMatchCandidates( + dto: FindMatchCandidatesDto, + tenantId: string, + bankAccountId?: string + ): Promise { + const { amount, date, reference, partner_id, amount_tolerance = 0, date_tolerance_days = 3, limit = 10 } = dto; + + let whereClause = ` + WHERE jel.tenant_id = $1 + AND je.status = 'posted' + AND NOT EXISTS ( + SELECT 1 FROM financial.bank_statement_lines bsl + WHERE bsl.reconciled_entry_id = jel.id + ) + `; + const params: unknown[] = [tenantId]; + let paramIndex = 2; + + // Filtrar por cuenta bancaria si se especifica + if (bankAccountId) { + whereClause += ` AND jel.account_id = $${paramIndex++}`; + params.push(bankAccountId); + } + + // Filtrar por monto con tolerancia + const amountMin = amount * (1 - amount_tolerance); + const amountMax = amount * (1 + amount_tolerance); + whereClause += ` AND ( + (jel.debit BETWEEN $${paramIndex} AND $${paramIndex + 1}) + OR (jel.credit BETWEEN $${paramIndex} AND $${paramIndex + 1}) + )`; + params.push(amountMin, amountMax); + paramIndex += 2; + + // Filtrar por fecha con tolerancia + if (date) { + whereClause += ` AND je.date BETWEEN ($${paramIndex}::date - interval '${date_tolerance_days} days') AND ($${paramIndex}::date + interval '${date_tolerance_days} days')`; + params.push(date); + paramIndex++; + } + + // Filtrar por partner si se especifica + if (partner_id) { + whereClause += ` AND jel.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + params.push(limit); + + const candidates = await query( + `SELECT + jel.id, + jel.entry_id, + je.name as entry_name, + je.ref as entry_ref, + je.date as entry_date, + jel.account_id, + a.code as account_code, + a.name as account_name, + jel.debit, + jel.credit, + (jel.debit - jel.credit) as net_amount, + jel.description, + jel.partner_id, + p.name as partner_name + FROM financial.journal_entry_lines jel + INNER JOIN financial.journal_entries je ON jel.entry_id = je.id + INNER JOIN financial.accounts a ON jel.account_id = a.id + LEFT JOIN core.partners p ON jel.partner_id = p.id + ${whereClause} + ORDER BY je.date DESC + LIMIT $${paramIndex}`, + params + ); + + // Calcular confianza y tipo de match para cada candidato + return candidates.map((c) => { + let confidence = 50; // Base + let matchType: MatchCandidate['match_type'] = 'exact_amount'; + + const candidateAmount = Math.abs(Number(c.debit) - Number(c.credit)); + + // Match exacto de monto + if (Math.abs(candidateAmount - amount) < 0.01) { + confidence += 30; + matchType = 'exact_amount'; + } + + // Match de fecha exacta + if (date && c.entry_date.toString().substring(0, 10) === date.substring(0, 10)) { + confidence += 15; + matchType = 'amount_date'; + } + + // Match de referencia + if (reference && c.entry_ref && c.entry_ref.toLowerCase().includes(reference.toLowerCase())) { + confidence += 20; + matchType = 'reference'; + } + + // Match de partner + if (partner_id && c.partner_id === partner_id) { + confidence += 15; + matchType = 'partner'; + } + + return { + ...c, + match_type: matchType, + confidence: Math.min(100, confidence), + }; + }); + } + + /** + * Conciliar manualmente una linea de extracto con una linea de asiento + */ + async reconcileLine( + lineId: string, + entryLineId: string, + tenantId: string, + userId: string + ): Promise { + // Verificar que la linea de extracto existe y no esta conciliada + const line = await queryOne( + `SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`, + [lineId, tenantId] + ); + + if (!line) { + throw new NotFoundError('Linea de extracto no encontrada'); + } + + if (line.is_reconciled) { + throw new ValidationError('La linea ya esta conciliada'); + } + + // Verificar que la linea de asiento existe y no esta conciliada con otra linea + const entryLine = await queryOne<{ id: string; debit: number; credit: number }>( + `SELECT jel.* FROM financial.journal_entry_lines jel + INNER JOIN financial.journal_entries je ON jel.entry_id = je.id + WHERE jel.id = $1 AND jel.tenant_id = $2 AND je.status = 'posted'`, + [entryLineId, tenantId] + ); + + if (!entryLine) { + throw new NotFoundError('Linea de asiento no encontrada o no publicada'); + } + + // Verificar que no este ya conciliada + const alreadyReconciled = await queryOne<{ id: string }>( + `SELECT id FROM financial.bank_statement_lines WHERE reconciled_entry_id = $1`, + [entryLineId] + ); + + if (alreadyReconciled) { + throw new ValidationError('La linea de asiento ya esta conciliada con otra linea de extracto'); + } + + // Actualizar la linea de extracto + await query( + `UPDATE financial.bank_statement_lines SET + is_reconciled = true, + reconciled_entry_id = $1, + reconciled_at = CURRENT_TIMESTAMP, + reconciled_by = $2 + WHERE id = $3`, + [entryLineId, userId, lineId] + ); + + return { + success: true, + statement_line_id: lineId, + entry_line_id: entryLineId, + }; + } + + /** + * Deshacer la conciliacion de una linea + */ + async unreconcileLine(lineId: string, tenantId: string): Promise { + const line = await queryOne( + `SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`, + [lineId, tenantId] + ); + + if (!line) { + throw new NotFoundError('Linea de extracto no encontrada'); + } + + if (!line.is_reconciled) { + throw new ValidationError('La linea no esta conciliada'); + } + + await query( + `UPDATE financial.bank_statement_lines SET + is_reconciled = false, + reconciled_entry_id = NULL, + reconciled_at = NULL, + reconciled_by = NULL + WHERE id = $1`, + [lineId] + ); + } + + /** + * Cerrar un extracto completamente conciliado + */ + async closeStatement(statementId: string, tenantId: string, userId: string): Promise { + const statement = await this.getStatementWithLines(statementId, tenantId); + + if (statement.status === 'reconciled') { + throw new ValidationError('El extracto ya esta cerrado'); + } + + // Verificar que todas las lineas esten conciliadas + const unreconciledLines = statement.lines.filter((l) => !l.is_reconciled); + if (unreconciledLines.length > 0) { + throw new ValidationError( + `No se puede cerrar el extracto. Hay ${unreconciledLines.length} linea(s) sin conciliar` + ); + } + + await query( + `UPDATE financial.bank_statements SET + status = 'reconciled', + reconciled_at = CURRENT_TIMESTAMP, + reconciled_by = $1, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [userId, statementId] + ); + + return this.findById(statementId, tenantId); + } + + /** + * Obtener un extracto por ID + */ + async findById(id: string, tenantId: string): Promise { + const statement = await queryOne( + `SELECT bs.*, a.name as bank_account_name + FROM financial.bank_statements bs + LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id + WHERE bs.id = $1 AND bs.tenant_id = $2`, + [id, tenantId] + ); + + if (!statement) { + throw new NotFoundError('Extracto bancario no encontrado'); + } + + return statement; + } + + // ========================================== + // REGLAS DE CONCILIACION + // ========================================== + + /** + * Crear una regla de conciliacion + */ + async createRule(dto: CreateReconciliationRuleDto, tenantId: string, userId: string): Promise { + const result = await queryOne( + `INSERT INTO financial.bank_reconciliation_rules ( + tenant_id, company_id, name, match_type, match_value, + auto_account_id, priority, is_active, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, + dto.company_id || null, + dto.name, + dto.match_type, + dto.match_value, + dto.auto_account_id || null, + dto.priority || 0, + dto.is_active !== false, + userId, + ] + ); + + return result!; + } + + /** + * Obtener reglas de conciliacion + */ + async findRules(tenantId: string, companyId?: string): Promise { + let whereClause = 'WHERE r.tenant_id = $1'; + const params: unknown[] = [tenantId]; + + if (companyId) { + whereClause += ' AND (r.company_id = $2 OR r.company_id IS NULL)'; + params.push(companyId); + } + + return query( + `SELECT r.*, a.name as auto_account_name + FROM financial.bank_reconciliation_rules r + LEFT JOIN financial.accounts a ON r.auto_account_id = a.id + ${whereClause} + ORDER BY r.priority DESC, r.name`, + params + ); + } + + /** + * Actualizar una regla de conciliacion + */ + async updateRule( + id: string, + dto: UpdateReconciliationRuleDto, + tenantId: string, + userId: string + ): Promise { + const existing = await queryOne( + `SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!existing) { + throw new NotFoundError('Regla de conciliacion no encontrada'); + } + + const updateFields: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.match_type !== undefined) { + updateFields.push(`match_type = $${paramIndex++}`); + values.push(dto.match_type); + } + if (dto.match_value !== undefined) { + updateFields.push(`match_value = $${paramIndex++}`); + values.push(dto.match_value); + } + if (dto.auto_account_id !== undefined) { + updateFields.push(`auto_account_id = $${paramIndex++}`); + values.push(dto.auto_account_id); + } + if (dto.priority !== undefined) { + updateFields.push(`priority = $${paramIndex++}`); + values.push(dto.priority); + } + if (dto.is_active !== undefined) { + updateFields.push(`is_active = $${paramIndex++}`); + values.push(dto.is_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 financial.bank_reconciliation_rules SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findRuleById(id, tenantId); + } + + /** + * Obtener regla por ID + */ + async findRuleById(id: string, tenantId: string): Promise { + const rule = await queryOne( + `SELECT r.*, a.name as auto_account_name + FROM financial.bank_reconciliation_rules r + LEFT JOIN financial.accounts a ON r.auto_account_id = a.id + WHERE r.id = $1 AND r.tenant_id = $2`, + [id, tenantId] + ); + + if (!rule) { + throw new NotFoundError('Regla de conciliacion no encontrada'); + } + + return rule; + } + + /** + * Eliminar una regla + */ + async deleteRule(id: string, tenantId: string): Promise { + const existing = await queryOne( + `SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!existing) { + throw new NotFoundError('Regla de conciliacion no encontrada'); + } + + await query(`DELETE FROM financial.bank_reconciliation_rules WHERE id = $1`, [id]); + } + + // ========================================== + // HELPERS + // ========================================== + + /** + * Aplicar una regla a una linea de extracto + */ + private applyRule( + rule: ReconciliationRule, + line: BankStatementLineResponse + ): boolean { + switch (rule.match_type) { + case 'exact_amount': + return Math.abs(Number(line.amount)) === parseFloat(rule.match_value); + + case 'reference_contains': + return line.reference?.toLowerCase().includes(rule.match_value.toLowerCase()) || false; + + case 'partner_name': + // Esto requeriria el nombre del partner, que ya esta en partner_name + return line.partner_name?.toLowerCase().includes(rule.match_value.toLowerCase()) || false; + + default: + return false; + } + } +} + +export const bankReconciliationService = new BankReconciliationService(); diff --git a/src/modules/purchases/dto/index.ts b/src/modules/purchases/dto/index.ts index 1e79596..ddd5f0b 100644 --- a/src/modules/purchases/dto/index.ts +++ b/src/modules/purchases/dto/index.ts @@ -37,3 +37,7 @@ export class UpdatePurchaseOrderDto { @IsOptional() @IsString() notes?: string; @IsOptional() @IsEnum(['draft', 'sent', 'confirmed', 'partial', 'received', 'cancelled']) status?: string; } + +// 3-Way Matching DTOs +export * from './matching-status.dto'; +export * from './resolve-exception.dto'; diff --git a/src/modules/purchases/dto/matching-status.dto.ts b/src/modules/purchases/dto/matching-status.dto.ts new file mode 100644 index 0000000..7fadecb --- /dev/null +++ b/src/modules/purchases/dto/matching-status.dto.ts @@ -0,0 +1,180 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsArray, ValidateNested, IsEnum } from 'class-validator'; +import { Type } from 'class-transformer'; +import { MatchingStatus } from '../entities/purchase-order-matching.entity'; +import { MatchingLineStatus } from '../entities/purchase-matching-line.entity'; +import { ExceptionType, ExceptionStatus } from '../entities/matching-exception.entity'; + +/** + * DTO for matching line status information + */ +export class MatchingLineStatusDto { + @IsUUID() + id: string; + + @IsUUID() + orderItemId: string; + + @IsString() + productName: string; + + @IsNumber() + qtyOrdered: number; + + @IsNumber() + qtyReceived: number; + + @IsNumber() + qtyInvoiced: number; + + @IsNumber() + priceOrdered: number; + + @IsNumber() + priceInvoiced: number; + + @IsNumber() + qtyVariance: number; + + @IsNumber() + invoiceQtyVariance: number; + + @IsNumber() + priceVariance: number; + + @IsString() + status: MatchingLineStatus; +} + +/** + * DTO for exception information + */ +export class MatchingExceptionDto { + @IsUUID() + id: string; + + @IsEnum(['over_receipt', 'short_receipt', 'over_invoice', 'short_invoice', 'price_variance']) + exceptionType: ExceptionType; + + @IsOptional() + @IsNumber() + expectedValue?: number; + + @IsOptional() + @IsNumber() + actualValue?: number; + + @IsOptional() + @IsNumber() + varianceValue?: number; + + @IsOptional() + @IsNumber() + variancePercent?: number; + + @IsEnum(['pending', 'approved', 'rejected']) + status: ExceptionStatus; + + @IsOptional() + @IsString() + resolvedAt?: string; + + @IsOptional() + @IsString() + resolutionNotes?: string; +} + +/** + * DTO for complete matching status response + */ +export class MatchingStatusDto { + @IsUUID() + id: string; + + @IsUUID() + purchaseOrderId: string; + + @IsString() + orderNumber: string; + + @IsEnum(['pending', 'partial_receipt', 'received', 'partial_invoice', 'matched', 'mismatch']) + status: MatchingStatus; + + @IsNumber() + totalOrdered: number; + + @IsNumber() + totalReceived: number; + + @IsNumber() + totalInvoiced: number; + + @IsNumber() + receiptVariance: number; + + @IsNumber() + invoiceVariance: number; + + @IsOptional() + @IsUUID() + lastReceiptId?: string; + + @IsOptional() + @IsUUID() + lastInvoiceId?: string; + + @IsOptional() + @IsString() + matchedAt?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MatchingLineStatusDto) + lines: MatchingLineStatusDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MatchingExceptionDto) + exceptions: MatchingExceptionDto[]; + + @IsNumber() + pendingExceptionsCount: number; +} + +/** + * DTO for matching summary (list view) + */ +export class MatchingSummaryDto { + @IsUUID() + id: string; + + @IsUUID() + purchaseOrderId: string; + + @IsString() + orderNumber: string; + + @IsString() + supplierName: string; + + @IsEnum(['pending', 'partial_receipt', 'received', 'partial_invoice', 'matched', 'mismatch']) + status: MatchingStatus; + + @IsNumber() + totalOrdered: number; + + @IsNumber() + totalReceived: number; + + @IsNumber() + totalInvoiced: number; + + @IsNumber() + pendingExceptionsCount: number; + + @IsString() + createdAt: string; + + @IsOptional() + @IsString() + matchedAt?: string; +} diff --git a/src/modules/purchases/dto/resolve-exception.dto.ts b/src/modules/purchases/dto/resolve-exception.dto.ts new file mode 100644 index 0000000..7704e8e --- /dev/null +++ b/src/modules/purchases/dto/resolve-exception.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsOptional, IsEnum, MaxLength } from 'class-validator'; +import { ExceptionStatus } from '../entities/matching-exception.entity'; + +/** + * DTO for resolving a matching exception + */ +export class ResolveExceptionDto { + @IsEnum(['approved', 'rejected']) + resolution: Exclude; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} + +/** + * DTO for bulk resolving exceptions + */ +export class BulkResolveExceptionsDto { + @IsString({ each: true }) + exceptionIds: string[]; + + @IsEnum(['approved', 'rejected']) + resolution: Exclude; + + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} diff --git a/src/modules/purchases/entities/index.ts b/src/modules/purchases/entities/index.ts index d4c36a1..29fe145 100644 --- a/src/modules/purchases/entities/index.ts +++ b/src/modules/purchases/entities/index.ts @@ -2,3 +2,6 @@ export { PurchaseOrder } from './purchase-order.entity'; export { PurchaseOrderItem } from './purchase-order-item.entity'; export { PurchaseReceipt } from './purchase-receipt.entity'; export { PurchaseReceiptItem } from './purchase-receipt-item.entity'; +export { PurchaseOrderMatching, MatchingStatus } from './purchase-order-matching.entity'; +export { PurchaseMatchingLine, MatchingLineStatus } from './purchase-matching-line.entity'; +export { MatchingException, ExceptionType, ExceptionStatus } from './matching-exception.entity'; diff --git a/src/modules/purchases/entities/matching-exception.entity.ts b/src/modules/purchases/entities/matching-exception.entity.ts new file mode 100644 index 0000000..a1ffb37 --- /dev/null +++ b/src/modules/purchases/entities/matching-exception.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrderMatching } from './purchase-order-matching.entity'; +import { PurchaseMatchingLine } from './purchase-matching-line.entity'; + +export type ExceptionType = + | 'over_receipt' + | 'short_receipt' + | 'over_invoice' + | 'short_invoice' + | 'price_variance'; + +export type ExceptionStatus = 'pending' | 'approved' | 'rejected'; + +@Entity({ name: 'matching_exceptions', schema: 'purchases' }) +export class MatchingException { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'matching_id', type: 'uuid', nullable: true }) + matchingId?: string; + + @ManyToOne(() => PurchaseOrderMatching, (matching) => matching.exceptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_id' }) + matching?: PurchaseOrderMatching; + + @Index() + @Column({ name: 'matching_line_id', type: 'uuid', nullable: true }) + matchingLineId?: string; + + @ManyToOne(() => PurchaseMatchingLine, (line) => line.exceptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_line_id' }) + matchingLine?: PurchaseMatchingLine; + + @Index() + @Column({ name: 'exception_type', type: 'varchar', length: 50 }) + exceptionType: ExceptionType; + + @Column({ name: 'expected_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + expectedValue?: number; + + @Column({ name: 'actual_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + actualValue?: number; + + @Column({ name: 'variance_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + varianceValue?: number; + + @Column({ name: 'variance_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + variancePercent?: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ExceptionStatus; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt?: Date; + + @Column({ name: 'resolved_by', type: 'uuid', nullable: true }) + resolvedBy?: string; + + @Column({ name: 'resolution_notes', type: 'text', nullable: true }) + resolutionNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-matching-line.entity.ts b/src/modules/purchases/entities/purchase-matching-line.entity.ts new file mode 100644 index 0000000..72e4f35 --- /dev/null +++ b/src/modules/purchases/entities/purchase-matching-line.entity.ts @@ -0,0 +1,103 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrderMatching } from './purchase-order-matching.entity'; +import { PurchaseOrderItem } from './purchase-order-item.entity'; +import { MatchingException } from './matching-exception.entity'; + +export type MatchingLineStatus = 'pending' | 'partial' | 'matched' | 'mismatch'; + +@Entity({ name: 'purchase_matching_lines', schema: 'purchases' }) +export class PurchaseMatchingLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'matching_id', type: 'uuid' }) + matchingId: string; + + @ManyToOne(() => PurchaseOrderMatching, (matching) => matching.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_id' }) + matching: PurchaseOrderMatching; + + @Index() + @Column({ name: 'order_item_id', type: 'uuid' }) + orderItemId: string; + + @ManyToOne(() => PurchaseOrderItem, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'order_item_id' }) + orderItem: PurchaseOrderItem; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Quantities + @Column({ name: 'qty_ordered', type: 'decimal', precision: 15, scale: 4 }) + qtyOrdered: number; + + @Column({ name: 'qty_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + qtyReceived: number; + + @Column({ name: 'qty_invoiced', type: 'decimal', precision: 15, scale: 4, default: 0 }) + qtyInvoiced: number; + + // Prices + @Column({ name: 'price_ordered', type: 'decimal', precision: 15, scale: 2 }) + priceOrdered: number; + + @Column({ name: 'price_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 }) + priceInvoiced: number; + + // Generated columns (read-only in TypeORM) + @Column({ + name: 'qty_variance', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + nullable: true, + }) + qtyVariance: number; + + @Column({ + name: 'invoice_qty_variance', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + nullable: true, + }) + invoiceQtyVariance: number; + + @Column({ + name: 'price_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + priceVariance: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: MatchingLineStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @OneToMany(() => MatchingException, (exception) => exception.matchingLine) + exceptions: MatchingException[]; +} diff --git a/src/modules/purchases/entities/purchase-order-matching.entity.ts b/src/modules/purchases/entities/purchase-order-matching.entity.ts new file mode 100644 index 0000000..0516a7f --- /dev/null +++ b/src/modules/purchases/entities/purchase-order-matching.entity.ts @@ -0,0 +1,107 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrder } from './purchase-order.entity'; +import { PurchaseReceipt } from './purchase-receipt.entity'; +import { PurchaseMatchingLine } from './purchase-matching-line.entity'; +import { MatchingException } from './matching-exception.entity'; + +export type MatchingStatus = + | 'pending' + | 'partial_receipt' + | 'received' + | 'partial_invoice' + | 'matched' + | 'mismatch'; + +@Entity({ name: 'purchase_order_matching', schema: 'purchases' }) +export class PurchaseOrderMatching { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'purchase_order_id', type: 'uuid' }) + purchaseOrderId: string; + + @ManyToOne(() => PurchaseOrder, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'purchase_order_id' }) + purchaseOrder: PurchaseOrder; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: MatchingStatus; + + @Column({ name: 'total_ordered', type: 'decimal', precision: 15, scale: 2 }) + totalOrdered: number; + + @Column({ name: 'total_received', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalReceived: number; + + @Column({ name: 'total_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalInvoiced: number; + + // Generated columns (read-only in TypeORM) + @Column({ + name: 'receipt_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + receiptVariance: number; + + @Column({ + name: 'invoice_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + invoiceVariance: number; + + @Index() + @Column({ name: 'last_receipt_id', type: 'uuid', nullable: true }) + lastReceiptId?: string; + + @ManyToOne(() => PurchaseReceipt, { nullable: true }) + @JoinColumn({ name: 'last_receipt_id' }) + lastReceipt?: PurchaseReceipt; + + @Column({ name: 'last_invoice_id', type: 'uuid', nullable: true }) + lastInvoiceId?: string; + + @Column({ name: 'matched_at', type: 'timestamptz', nullable: true }) + matchedAt?: Date; + + @Column({ name: 'matched_by', type: 'uuid', nullable: true }) + matchedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @OneToMany(() => PurchaseMatchingLine, (line) => line.matching) + lines: PurchaseMatchingLine[]; + + @OneToMany(() => MatchingException, (exception) => exception.matching) + exceptions: MatchingException[]; +} diff --git a/src/modules/purchases/services/index.ts b/src/modules/purchases/services/index.ts index 72daa36..6ff21d2 100644 --- a/src/modules/purchases/services/index.ts +++ b/src/modules/purchases/services/index.ts @@ -2,6 +2,9 @@ import { Repository, FindOptionsWhere } from 'typeorm'; import { PurchaseOrder } from '../entities'; import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto'; +// Export 3-Way Matching Service +export { ThreeWayMatchingService, MatchingSearchParams, InvoiceData, InvoiceLineData } from './three-way-matching.service'; + export interface PurchaseSearchParams { tenantId: string; supplierId?: string; diff --git a/src/modules/purchases/services/three-way-matching.service.ts b/src/modules/purchases/services/three-way-matching.service.ts new file mode 100644 index 0000000..4158ceb --- /dev/null +++ b/src/modules/purchases/services/three-way-matching.service.ts @@ -0,0 +1,740 @@ +import { Repository, DataSource } from 'typeorm'; +import { + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + PurchaseOrderMatching, + PurchaseMatchingLine, + MatchingException, + MatchingStatus, + MatchingLineStatus, + ExceptionType, +} from '../entities'; +import { + MatchingStatusDto, + MatchingLineStatusDto, + MatchingExceptionDto, + MatchingSummaryDto, + ResolveExceptionDto, +} from '../dto'; + +/** + * Tolerance configuration for matching validation + */ +const TOLERANCES = { + QUANTITY_PERCENT: 0.5, // 0.5% tolerance for quantity variance + PRICE_PERCENT: 2.0, // 2% tolerance for price variance +}; + +export interface MatchingSearchParams { + tenantId: string; + status?: MatchingStatus; + supplierId?: string; + hasExceptions?: boolean; + limit?: number; + offset?: number; +} + +/** + * Interface for invoice data (simplified for matching purposes) + */ +export interface InvoiceLineData { + orderItemId: string; + quantityInvoiced: number; + priceInvoiced: number; +} + +export interface InvoiceData { + invoiceId: string; + purchaseOrderId: string; + totalInvoiced: number; + lines: InvoiceLineData[]; +} + +export class ThreeWayMatchingService { + constructor( + private readonly matchingRepository: Repository, + private readonly matchingLineRepository: Repository, + private readonly exceptionRepository: Repository, + private readonly orderRepository: Repository, + private readonly orderItemRepository: Repository, + private readonly receiptRepository: Repository, + private readonly receiptItemRepository: Repository, + private readonly dataSource: DataSource + ) {} + + // ==================== Initialization ==================== + + /** + * Initialize matching record when a purchase order is confirmed + * Called when PO status changes to 'confirmed' + */ + async initializeMatching(purchaseOrderId: string, tenantId: string): Promise { + // Check if matching already exists + const existing = await this.matchingRepository.findOne({ + where: { purchaseOrderId, tenantId }, + }); + + if (existing) { + return existing; + } + + // Get order with items + const order = await this.orderRepository.findOne({ + where: { id: purchaseOrderId, tenantId }, + }); + + if (!order) { + throw new Error(`Purchase order ${purchaseOrderId} not found`); + } + + const orderItems = await this.orderItemRepository.find({ + where: { orderId: purchaseOrderId }, + }); + + if (orderItems.length === 0) { + throw new Error(`Purchase order ${purchaseOrderId} has no items`); + } + + // Create matching record within transaction + return this.dataSource.transaction(async (manager) => { + const matchingRepo = manager.getRepository(PurchaseOrderMatching); + const matchingLineRepo = manager.getRepository(PurchaseMatchingLine); + + const matching = matchingRepo.create({ + tenantId, + purchaseOrderId, + status: 'pending', + totalOrdered: Number(order.total), + totalReceived: 0, + totalInvoiced: 0, + }); + + const savedMatching = await matchingRepo.save(matching); + + // Create matching lines for each order item + const lines = orderItems.map((item) => + matchingLineRepo.create({ + matchingId: savedMatching.id, + orderItemId: item.id, + tenantId, + qtyOrdered: Number(item.quantity), + qtyReceived: 0, + qtyInvoiced: 0, + priceOrdered: Number(item.unitPrice), + priceInvoiced: 0, + status: 'pending', + }) + ); + + await matchingLineRepo.save(lines); + + return savedMatching; + }); + } + + // ==================== Receipt Updates ==================== + + /** + * Update matching quantities from a confirmed receipt + */ + async updateFromReceipt(receiptId: string, tenantId: string): Promise { + const receipt = await this.receiptRepository.findOne({ + where: { id: receiptId, tenantId }, + }); + + if (!receipt) { + throw new Error(`Receipt ${receiptId} not found`); + } + + const receiptItems = await this.receiptItemRepository.find({ + where: { receiptId }, + }); + + // Get or create matching record + let matching = await this.matchingRepository.findOne({ + where: { purchaseOrderId: receipt.orderId, tenantId }, + }); + + if (!matching) { + matching = await this.initializeMatching(receipt.orderId, tenantId); + } + + return this.dataSource.transaction(async (manager) => { + const matchingRepo = manager.getRepository(PurchaseOrderMatching); + const matchingLineRepo = manager.getRepository(PurchaseMatchingLine); + const exceptionRepo = manager.getRepository(MatchingException); + + // Update matching lines from receipt items + for (const receiptItem of receiptItems) { + if (!receiptItem.orderItemId) continue; + + const matchingLine = await matchingLineRepo.findOne({ + where: { matchingId: matching!.id, orderItemId: receiptItem.orderItemId }, + }); + + if (matchingLine) { + // Accumulate received quantity + matchingLine.qtyReceived = Number(matchingLine.qtyReceived) + Number(receiptItem.quantityReceived); + await matchingLineRepo.save(matchingLine); + + // Check for receipt quantity exceptions + await this.checkReceiptQuantityException( + matchingLine, + exceptionRepo, + tenantId + ); + } + } + + // Recalculate totals + const lines = await matchingLineRepo.find({ + where: { matchingId: matching!.id }, + }); + + const orderItems = await this.orderItemRepository.find({ + where: { orderId: matching!.purchaseOrderId }, + }); + + // Create a map for quick lookup + const orderItemMap = new Map(orderItems.map((item) => [item.id, item])); + + let totalReceived = 0; + for (const line of lines) { + const orderItem = orderItemMap.get(line.orderItemId); + if (orderItem) { + totalReceived += Number(line.qtyReceived) * Number(orderItem.unitPrice); + } + } + + matching!.totalReceived = totalReceived; + matching!.lastReceiptId = receiptId; + + // Update status + matching!.status = this.calculateMatchingStatus(matching!, lines); + + return matchingRepo.save(matching!); + }); + } + + // ==================== Invoice Updates ==================== + + /** + * Update matching from an invoice + */ + async updateFromInvoice(invoiceData: InvoiceData, tenantId: string): Promise { + // Get or create matching record + let matching = await this.matchingRepository.findOne({ + where: { purchaseOrderId: invoiceData.purchaseOrderId, tenantId }, + }); + + if (!matching) { + matching = await this.initializeMatching(invoiceData.purchaseOrderId, tenantId); + } + + return this.dataSource.transaction(async (manager) => { + const matchingRepo = manager.getRepository(PurchaseOrderMatching); + const matchingLineRepo = manager.getRepository(PurchaseMatchingLine); + const exceptionRepo = manager.getRepository(MatchingException); + + // Update matching lines from invoice lines + for (const invoiceLine of invoiceData.lines) { + const matchingLine = await matchingLineRepo.findOne({ + where: { matchingId: matching!.id, orderItemId: invoiceLine.orderItemId }, + }); + + if (matchingLine) { + // Accumulate invoiced quantity and update price + matchingLine.qtyInvoiced = Number(matchingLine.qtyInvoiced) + invoiceLine.quantityInvoiced; + matchingLine.priceInvoiced = invoiceLine.priceInvoiced; + await matchingLineRepo.save(matchingLine); + + // Check for invoice exceptions + await this.checkInvoiceExceptions( + matchingLine, + exceptionRepo, + tenantId + ); + } + } + + // Update totals + matching!.totalInvoiced = Number(matching!.totalInvoiced) + invoiceData.totalInvoiced; + matching!.lastInvoiceId = invoiceData.invoiceId; + + // Recalculate status + const lines = await matchingLineRepo.find({ + where: { matchingId: matching!.id }, + }); + matching!.status = this.calculateMatchingStatus(matching!, lines); + + return matchingRepo.save(matching!); + }); + } + + // ==================== Matching Validation ==================== + + /** + * Perform complete matching validation for an order + */ + async performMatching(matchingId: string, tenantId: string, userId?: string): Promise { + const matching = await this.matchingRepository.findOne({ + where: { id: matchingId, tenantId }, + }); + + if (!matching) { + throw new Error(`Matching record ${matchingId} not found`); + } + + return this.dataSource.transaction(async (manager) => { + const matchingRepo = manager.getRepository(PurchaseOrderMatching); + const matchingLineRepo = manager.getRepository(PurchaseMatchingLine); + const exceptionRepo = manager.getRepository(MatchingException); + + const lines = await matchingLineRepo.find({ + where: { matchingId }, + }); + + // Validate each line + for (const line of lines) { + // Check receipt quantity variance + await this.checkReceiptQuantityException(line, exceptionRepo, tenantId); + + // Check invoice quantity variance + await this.checkInvoiceExceptions(line, exceptionRepo, tenantId); + + // Update line status + line.status = this.calculateLineStatus(line); + await matchingLineRepo.save(line); + } + + // Check if all lines are matched and no pending exceptions + const pendingExceptions = await exceptionRepo.count({ + where: { matchingId, status: 'pending' }, + }); + + const allLinesMatched = lines.every((line) => line.status === 'matched'); + + if (allLinesMatched && pendingExceptions === 0) { + matching.status = 'matched'; + matching.matchedAt = new Date(); + matching.matchedBy = userId; + } else if (lines.some((line) => line.status === 'mismatch') || pendingExceptions > 0) { + matching.status = 'mismatch'; + } + + return matchingRepo.save(matching); + }); + } + + // ==================== Status Queries ==================== + + /** + * Get complete matching status for a purchase order + */ + async getMatchingStatus(purchaseOrderId: string, tenantId: string): Promise { + const matching = await this.matchingRepository.findOne({ + where: { purchaseOrderId, tenantId }, + relations: ['purchaseOrder'], + }); + + if (!matching) { + return null; + } + + const lines = await this.matchingLineRepository.find({ + where: { matchingId: matching.id }, + }); + + const orderItems = await this.orderItemRepository.find({ + where: { orderId: purchaseOrderId }, + }); + + const orderItemMap = new Map(orderItems.map((item) => [item.id, item])); + + const exceptions = await this.exceptionRepository.find({ + where: { matchingId: matching.id }, + order: { createdAt: 'DESC' }, + }); + + const pendingExceptionsCount = exceptions.filter((e) => e.status === 'pending').length; + + const lineStatuses: MatchingLineStatusDto[] = lines.map((line) => { + const orderItem = orderItemMap.get(line.orderItemId); + return { + id: line.id, + orderItemId: line.orderItemId, + productName: orderItem?.productName || 'Unknown', + qtyOrdered: Number(line.qtyOrdered), + qtyReceived: Number(line.qtyReceived), + qtyInvoiced: Number(line.qtyInvoiced), + priceOrdered: Number(line.priceOrdered), + priceInvoiced: Number(line.priceInvoiced), + qtyVariance: Number(line.qtyVariance) || 0, + invoiceQtyVariance: Number(line.invoiceQtyVariance) || 0, + priceVariance: Number(line.priceVariance) || 0, + status: line.status, + }; + }); + + const exceptionDtos: MatchingExceptionDto[] = exceptions.map((exc) => ({ + id: exc.id, + exceptionType: exc.exceptionType, + expectedValue: exc.expectedValue ? Number(exc.expectedValue) : undefined, + actualValue: exc.actualValue ? Number(exc.actualValue) : undefined, + varianceValue: exc.varianceValue ? Number(exc.varianceValue) : undefined, + variancePercent: exc.variancePercent ? Number(exc.variancePercent) : undefined, + status: exc.status, + resolvedAt: exc.resolvedAt?.toISOString(), + resolutionNotes: exc.resolutionNotes, + })); + + return { + id: matching.id, + purchaseOrderId: matching.purchaseOrderId, + orderNumber: matching.purchaseOrder?.orderNumber || '', + status: matching.status, + totalOrdered: Number(matching.totalOrdered), + totalReceived: Number(matching.totalReceived), + totalInvoiced: Number(matching.totalInvoiced), + receiptVariance: Number(matching.receiptVariance) || 0, + invoiceVariance: Number(matching.invoiceVariance) || 0, + lastReceiptId: matching.lastReceiptId, + lastInvoiceId: matching.lastInvoiceId, + matchedAt: matching.matchedAt?.toISOString(), + lines: lineStatuses, + exceptions: exceptionDtos, + pendingExceptionsCount, + }; + } + + /** + * Get list of matchings with summary + */ + async getMatchingList(params: MatchingSearchParams): Promise<{ data: MatchingSummaryDto[]; total: number }> { + const { tenantId, status, supplierId, hasExceptions, limit = 50, offset = 0 } = params; + + const qb = this.matchingRepository + .createQueryBuilder('matching') + .leftJoinAndSelect('matching.purchaseOrder', 'po') + .where('matching.tenant_id = :tenantId', { tenantId }); + + if (status) { + qb.andWhere('matching.status = :status', { status }); + } + + if (supplierId) { + qb.andWhere('po.supplier_id = :supplierId', { supplierId }); + } + + const [matchings, total] = await qb + .orderBy('matching.created_at', 'DESC') + .take(limit) + .skip(offset) + .getManyAndCount(); + + // Get exception counts + const matchingIds = matchings.map((m) => m.id); + let exceptionCounts: Map = new Map(); + + if (matchingIds.length > 0) { + const countResults = await this.exceptionRepository + .createQueryBuilder('exc') + .select('exc.matching_id', 'matchingId') + .addSelect('COUNT(*)', 'count') + .where('exc.matching_id IN (:...matchingIds)', { matchingIds }) + .andWhere('exc.status = :status', { status: 'pending' }) + .groupBy('exc.matching_id') + .getRawMany(); + + exceptionCounts = new Map(countResults.map((r) => [r.matchingId, parseInt(r.count)])); + } + + const data: MatchingSummaryDto[] = matchings + .filter((matching) => { + if (hasExceptions === true) { + return (exceptionCounts.get(matching.id) || 0) > 0; + } + if (hasExceptions === false) { + return (exceptionCounts.get(matching.id) || 0) === 0; + } + return true; + }) + .map((matching) => ({ + id: matching.id, + purchaseOrderId: matching.purchaseOrderId, + orderNumber: matching.purchaseOrder?.orderNumber || '', + supplierName: matching.purchaseOrder?.supplierName || '', + status: matching.status, + totalOrdered: Number(matching.totalOrdered), + totalReceived: Number(matching.totalReceived), + totalInvoiced: Number(matching.totalInvoiced), + pendingExceptionsCount: exceptionCounts.get(matching.id) || 0, + createdAt: matching.createdAt.toISOString(), + matchedAt: matching.matchedAt?.toISOString(), + })); + + return { data, total }; + } + + // ==================== Exception Management ==================== + + /** + * Resolve an exception + */ + async resolveException( + exceptionId: string, + tenantId: string, + dto: ResolveExceptionDto, + userId: string + ): Promise { + const exception = await this.exceptionRepository.findOne({ + where: { id: exceptionId, tenantId }, + }); + + if (!exception) { + throw new Error(`Exception ${exceptionId} not found`); + } + + if (exception.status !== 'pending') { + throw new Error(`Exception ${exceptionId} is already resolved`); + } + + exception.status = dto.resolution; + exception.resolvedAt = new Date(); + exception.resolvedBy = userId; + exception.resolutionNotes = dto.notes; + + const saved = await this.exceptionRepository.save(exception); + + // Re-evaluate matching status after resolving exception + if (exception.matchingId) { + await this.performMatching(exception.matchingId, tenantId, userId); + } + + return saved; + } + + /** + * Get pending exceptions for a tenant + */ + async getPendingExceptions( + tenantId: string, + limit = 50, + offset = 0 + ): Promise<{ data: MatchingException[]; total: number }> { + const [data, total] = await this.exceptionRepository.findAndCount({ + where: { tenantId, status: 'pending' }, + order: { createdAt: 'DESC' }, + take: limit, + skip: offset, + }); + + return { data, total }; + } + + // ==================== Private Helper Methods ==================== + + /** + * Check and create receipt quantity exceptions + */ + private async checkReceiptQuantityException( + line: PurchaseMatchingLine, + exceptionRepo: Repository, + tenantId: string + ): Promise { + const qtyOrdered = Number(line.qtyOrdered); + const qtyReceived = Number(line.qtyReceived); + const variance = qtyOrdered - qtyReceived; + const variancePercent = qtyOrdered > 0 ? Math.abs(variance / qtyOrdered) * 100 : 0; + + // Check if variance exceeds tolerance + if (variancePercent > TOLERANCES.QUANTITY_PERCENT) { + const exceptionType: ExceptionType = variance > 0 ? 'short_receipt' : 'over_receipt'; + + // Check if exception already exists + const existing = await exceptionRepo.findOne({ + where: { + matchingLineId: line.id, + exceptionType, + status: 'pending', + }, + }); + + if (!existing) { + const exception = exceptionRepo.create({ + tenantId, + matchingId: line.matchingId, + matchingLineId: line.id, + exceptionType, + expectedValue: qtyOrdered, + actualValue: qtyReceived, + varianceValue: Math.abs(variance), + variancePercent, + status: 'pending', + }); + + await exceptionRepo.save(exception); + } + } + } + + /** + * Check and create invoice exceptions (quantity and price) + */ + private async checkInvoiceExceptions( + line: PurchaseMatchingLine, + exceptionRepo: Repository, + tenantId: string + ): Promise { + const qtyReceived = Number(line.qtyReceived); + const qtyInvoiced = Number(line.qtyInvoiced); + const priceOrdered = Number(line.priceOrdered); + const priceInvoiced = Number(line.priceInvoiced); + + // Check invoice quantity variance (invoice vs receipt) + if (qtyInvoiced > qtyReceived) { + const variance = qtyInvoiced - qtyReceived; + const variancePercent = qtyReceived > 0 ? (variance / qtyReceived) * 100 : 100; + + if (variancePercent > TOLERANCES.QUANTITY_PERCENT) { + const existing = await exceptionRepo.findOne({ + where: { + matchingLineId: line.id, + exceptionType: 'over_invoice', + status: 'pending', + }, + }); + + if (!existing) { + const exception = exceptionRepo.create({ + tenantId, + matchingId: line.matchingId, + matchingLineId: line.id, + exceptionType: 'over_invoice', + expectedValue: qtyReceived, + actualValue: qtyInvoiced, + varianceValue: variance, + variancePercent, + status: 'pending', + }); + + await exceptionRepo.save(exception); + } + } + } + + // Check price variance + if (priceInvoiced > 0 && priceOrdered > 0) { + const priceVarianceAmount = priceInvoiced - priceOrdered; + const priceVariancePercent = (priceVarianceAmount / priceOrdered) * 100; + + // Only create exception if invoiced price exceeds ordered price by more than tolerance + if (priceVariancePercent > TOLERANCES.PRICE_PERCENT) { + const existing = await exceptionRepo.findOne({ + where: { + matchingLineId: line.id, + exceptionType: 'price_variance', + status: 'pending', + }, + }); + + if (!existing) { + const exception = exceptionRepo.create({ + tenantId, + matchingId: line.matchingId, + matchingLineId: line.id, + exceptionType: 'price_variance', + expectedValue: priceOrdered, + actualValue: priceInvoiced, + varianceValue: priceVarianceAmount, + variancePercent: priceVariancePercent, + status: 'pending', + }); + + await exceptionRepo.save(exception); + } + } + } + } + + /** + * Calculate matching status based on current state + */ + private calculateMatchingStatus( + matching: PurchaseOrderMatching, + lines: PurchaseMatchingLine[] + ): MatchingStatus { + const totalOrdered = Number(matching.totalOrdered); + const totalReceived = Number(matching.totalReceived); + const totalInvoiced = Number(matching.totalInvoiced); + + // No receipts yet + if (totalReceived === 0) { + return 'pending'; + } + + // Partial receipt + if (totalReceived < totalOrdered * 0.995) { + return 'partial_receipt'; + } + + // Fully received, no invoice + if (totalInvoiced === 0) { + return 'received'; + } + + // Partial invoice + if (totalInvoiced < totalReceived * 0.995) { + return 'partial_invoice'; + } + + // Check all lines + const allMatched = lines.every((line) => this.calculateLineStatus(line) === 'matched'); + + return allMatched ? 'matched' : 'mismatch'; + } + + /** + * Calculate line status based on quantities and prices + */ + private calculateLineStatus(line: PurchaseMatchingLine): MatchingLineStatus { + const qtyOrdered = Number(line.qtyOrdered); + const qtyReceived = Number(line.qtyReceived); + const qtyInvoiced = Number(line.qtyInvoiced); + const priceOrdered = Number(line.priceOrdered); + const priceInvoiced = Number(line.priceInvoiced); + + // No activity yet + if (qtyReceived === 0 && qtyInvoiced === 0) { + return 'pending'; + } + + // Check quantity tolerances + const qtyReceivedVariance = Math.abs(qtyOrdered - qtyReceived) / qtyOrdered * 100; + const qtyInvoicedVariance = qtyReceived > 0 ? Math.abs(qtyReceived - qtyInvoiced) / qtyReceived * 100 : 0; + + // Check price tolerance (only if invoiced) + let priceVariance = 0; + if (priceInvoiced > 0 && priceOrdered > 0) { + priceVariance = Math.abs(priceInvoiced - priceOrdered) / priceOrdered * 100; + } + + // Partial if not fully received or invoiced + if (qtyReceived < qtyOrdered * 0.995 || qtyInvoiced < qtyReceived * 0.995) { + return 'partial'; + } + + // Mismatch if any variance exceeds tolerance + if ( + qtyReceivedVariance > TOLERANCES.QUANTITY_PERCENT || + qtyInvoicedVariance > TOLERANCES.QUANTITY_PERCENT || + priceVariance > TOLERANCES.PRICE_PERCENT + ) { + return 'mismatch'; + } + + return 'matched'; + } +}