[TASK-2026-01-20-003] feat: Add bank reconciliation and 3-way matching
Bank Reconciliation (financial module): - Entities: BankStatement, BankStatementLine, BankReconciliationRule - Service: BankReconciliationService with auto-reconcile - DTOs: CreateBankStatement, ReconcileLine 3-Way Matching (purchases module): - Entities: PurchaseOrderMatching, PurchaseMatchingLine, MatchingException - Service: ThreeWayMatchingService with tolerance validation - DTOs: MatchingStatus, ResolveException Tolerances: - Quantity: 0.5% - Price: 2% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f63c17df5c
commit
af3cc5a25d
145
src/modules/financial/dto/create-bank-statement.dto.ts
Normal file
145
src/modules/financial/dto/create-bank-statement.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
6
src/modules/financial/dto/index.ts
Normal file
6
src/modules/financial/dto/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* DTOs para el modulo de conciliacion bancaria
|
||||
*/
|
||||
|
||||
export * from './create-bank-statement.dto.js';
|
||||
export * from './reconcile-line.dto.js';
|
||||
171
src/modules/financial/dto/reconcile-line.dto.ts
Normal file
171
src/modules/financial/dto/reconcile-line.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
93
src/modules/financial/entities/bank-statement-line.entity.ts
Normal file
93
src/modules/financial/entities/bank-statement-line.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
111
src/modules/financial/entities/bank-statement.entity.ts
Normal file
111
src/modules/financial/entities/bank-statement.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
810
src/modules/financial/services/bank-reconciliation.service.ts
Normal file
810
src/modules/financial/services/bank-reconciliation.service.ts
Normal file
@ -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<BankStatement> {
|
||||
// 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<BankStatement>(
|
||||
`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<BankStatement>(
|
||||
`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<BankStatementWithLines> {
|
||||
const statement = await queryOne<BankStatementWithLines>(
|
||||
`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<BankStatementLineResponse>(
|
||||
`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<void> {
|
||||
const statement = await queryOne<BankStatement>(
|
||||
`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<AutoReconcileResult> {
|
||||
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<ReconciliationRule>(
|
||||
`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<MatchCandidate[]> {
|
||||
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<MatchCandidate>(
|
||||
`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<ReconcileResult> {
|
||||
// Verificar que la linea de extracto existe y no esta conciliada
|
||||
const line = await queryOne<BankStatementLineResponse>(
|
||||
`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<void> {
|
||||
const line = await queryOne<BankStatementLineResponse>(
|
||||
`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<BankStatement> {
|
||||
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<BankStatement> {
|
||||
const statement = await queryOne<BankStatement>(
|
||||
`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<ReconciliationRule> {
|
||||
const result = await queryOne<ReconciliationRule>(
|
||||
`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<ReconciliationRule[]> {
|
||||
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<ReconciliationRule>(
|
||||
`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<ReconciliationRule> {
|
||||
const existing = await queryOne<ReconciliationRule>(
|
||||
`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<ReconciliationRule> {
|
||||
const rule = await queryOne<ReconciliationRule>(
|
||||
`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<void> {
|
||||
const existing = await queryOne<ReconciliationRule>(
|
||||
`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();
|
||||
@ -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';
|
||||
|
||||
180
src/modules/purchases/dto/matching-status.dto.ts
Normal file
180
src/modules/purchases/dto/matching-status.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
31
src/modules/purchases/dto/resolve-exception.dto.ts
Normal file
31
src/modules/purchases/dto/resolve-exception.dto.ts
Normal file
@ -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<ExceptionStatus, 'pending'>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for bulk resolving exceptions
|
||||
*/
|
||||
export class BulkResolveExceptionsDto {
|
||||
@IsString({ each: true })
|
||||
exceptionIds: string[];
|
||||
|
||||
@IsEnum(['approved', 'rejected'])
|
||||
resolution: Exclude<ExceptionStatus, 'pending'>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
notes?: string;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
78
src/modules/purchases/entities/matching-exception.entity.ts
Normal file
78
src/modules/purchases/entities/matching-exception.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
103
src/modules/purchases/entities/purchase-matching-line.entity.ts
Normal file
103
src/modules/purchases/entities/purchase-matching-line.entity.ts
Normal file
@ -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[];
|
||||
}
|
||||
107
src/modules/purchases/entities/purchase-order-matching.entity.ts
Normal file
107
src/modules/purchases/entities/purchase-order-matching.entity.ts
Normal file
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
|
||||
740
src/modules/purchases/services/three-way-matching.service.ts
Normal file
740
src/modules/purchases/services/three-way-matching.service.ts
Normal file
@ -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<PurchaseOrderMatching>,
|
||||
private readonly matchingLineRepository: Repository<PurchaseMatchingLine>,
|
||||
private readonly exceptionRepository: Repository<MatchingException>,
|
||||
private readonly orderRepository: Repository<PurchaseOrder>,
|
||||
private readonly orderItemRepository: Repository<PurchaseOrderItem>,
|
||||
private readonly receiptRepository: Repository<PurchaseReceipt>,
|
||||
private readonly receiptItemRepository: Repository<PurchaseReceiptItem>,
|
||||
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<PurchaseOrderMatching> {
|
||||
// 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<PurchaseOrderMatching> {
|
||||
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<PurchaseOrderMatching> {
|
||||
// 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<PurchaseOrderMatching> {
|
||||
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<MatchingStatusDto | null> {
|
||||
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<string, number> = 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<MatchingException> {
|
||||
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<MatchingException>,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
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<MatchingException>,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user