[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() @IsString() notes?: string;
|
||||||
@IsOptional() @IsEnum(['draft', 'sent', 'confirmed', 'partial', 'received', 'cancelled']) status?: 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 { PurchaseOrderItem } from './purchase-order-item.entity';
|
||||||
export { PurchaseReceipt } from './purchase-receipt.entity';
|
export { PurchaseReceipt } from './purchase-receipt.entity';
|
||||||
export { PurchaseReceiptItem } from './purchase-receipt-item.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 { PurchaseOrder } from '../entities';
|
||||||
import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto';
|
import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto';
|
||||||
|
|
||||||
|
// Export 3-Way Matching Service
|
||||||
|
export { ThreeWayMatchingService, MatchingSearchParams, InvoiceData, InvoiceLineData } from './three-way-matching.service';
|
||||||
|
|
||||||
export interface PurchaseSearchParams {
|
export interface PurchaseSearchParams {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
supplierId?: 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