7.2 KiB
7.2 KiB
ET-PURCH-001: Implementación de Proveedores y Cotizaciones
Épica: MAI-004 - Compras e Inventarios Versión: 1.0 Fecha: 2025-11-17
1. Schemas SQL
CREATE SCHEMA IF NOT EXISTS purchases;
-- Tabla: suppliers
CREATE TABLE purchases.suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
constructora_id UUID NOT NULL REFERENCES public.constructoras(id),
tax_id VARCHAR(13) NOT NULL UNIQUE, -- RFC
legal_name VARCHAR(255) NOT NULL,
commercial_name VARCHAR(255),
business_type VARCHAR(100),
categories VARCHAR[] DEFAULT '{}',
contact_name VARCHAR(255),
contact_position VARCHAR(100),
contact_email VARCHAR(255),
contact_phone VARCHAR(20),
payment_terms_days INTEGER DEFAULT 30,
early_payment_discount DECIMAL(5,2) DEFAULT 0,
requires_advance BOOLEAN DEFAULT false,
bank_name VARCHAR(100),
account_number VARCHAR(20),
clabe VARCHAR(18),
rating DECIMAL(5,2) DEFAULT 0 CHECK (rating >= 0 AND rating <= 100),
certification_status VARCHAR(20) DEFAULT 'none' CHECK (certification_status IN ('none', 'in_evaluation', 'certified')),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'blocked')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_suppliers_constructora ON purchases.suppliers(constructora_id);
CREATE INDEX idx_suppliers_status ON purchases.suppliers(status);
CREATE INDEX idx_suppliers_rating ON purchases.suppliers(rating DESC);
-- Tabla: rfqs (Solicitudes de cotización)
CREATE TABLE purchases.rfqs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
constructora_id UUID NOT NULL,
project_id UUID REFERENCES projects.projects(id),
requested_by UUID NOT NULL,
request_date DATE DEFAULT CURRENT_DATE,
quote_due_date TIMESTAMP NOT NULL,
delivery_date DATE,
items JSONB NOT NULL,
delivery_address TEXT,
payment_terms VARCHAR(50),
includes_unloading BOOLEAN DEFAULT true,
invited_suppliers UUID[],
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'sent', 'closed', 'cancelled')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabla: quotes
CREATE TABLE purchases.quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rfq_id UUID NOT NULL REFERENCES purchases.rfqs(id),
supplier_id UUID NOT NULL REFERENCES purchases.suppliers(id),
items JSONB NOT NULL,
subtotal DECIMAL(15,2) NOT NULL,
tax DECIMAL(15,2),
total DECIMAL(15,2) NOT NULL,
delivery_date DATE,
payment_terms VARCHAR(50),
valid_until DATE,
notes TEXT,
attachments VARCHAR[],
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'submitted', 'accepted', 'rejected')),
submitted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabla: supplier_ratings (evaluaciones)
CREATE TABLE purchases.supplier_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
supplier_id UUID NOT NULL REFERENCES purchases.suppliers(id),
evaluation_period VARCHAR(7) NOT NULL, -- 2025-11
price_score DECIMAL(5,2) DEFAULT 0,
quality_score DECIMAL(5,2) DEFAULT 0,
delivery_score DECIMAL(5,2) DEFAULT 0,
service_score DECIMAL(5,2) DEFAULT 0,
payment_score DECIMAL(5,2) DEFAULT 0,
overall_rating DECIMAL(5,2) DEFAULT 0,
total_orders INTEGER DEFAULT 0,
total_amount DECIMAL(15,2) DEFAULT 0,
on_time_deliveries INTEGER DEFAULT 0,
late_deliveries INTEGER DEFAULT 0,
returns INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_supplier_period UNIQUE (supplier_id, evaluation_period)
);
2. TypeORM Entities
@Entity('suppliers', { schema: 'purchases' })
export class Supplier {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tax_id', length: 13, unique: true })
taxId: string;
@Column({ name: 'legal_name', length: 255 })
legalName: string;
@Column({ name: 'commercial_name', length: 255, nullable: true })
commercialName: string;
@Column({ type: 'varchar', array: true, default: '{}' })
categories: string[];
@Column({ name: 'contact_email', nullable: true })
contactEmail: string;
@Column({ name: 'payment_terms_days', default: 30 })
paymentTermsDays: number;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
rating: number;
@Column({ name: 'certification_status', default: 'none' })
certificationStatus: 'none' | 'in_evaluation' | 'certified';
@Column({ default: 'active' })
status: 'active' | 'inactive' | 'blocked';
}
@Entity('rfqs', { schema: 'purchases' })
export class RFQ {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ name: 'project_id' })
projectId: string;
@Column({ type: 'jsonb' })
items: any[];
@Column({ name: 'quote_due_date', type: 'timestamp' })
quoteDueDate: Date;
@Column({ name: 'invited_suppliers', type: 'uuid', array: true })
invitedSuppliers: string[];
@Column({ default: 'draft' })
status: 'draft' | 'sent' | 'closed' | 'cancelled';
}
3. Service (Métodos Clave)
@Injectable()
export class SupplierService {
async evaluateSupplier(supplierId: string, period: string): Promise<void> {
// Obtener OCs del período
const orders = await this.purchaseOrderRepo.find({
where: { supplierId, status: In(['received', 'partially_received']) },
// filtrar por período
});
const totalOrders = orders.length;
const totalAmount = orders.reduce((sum, o) => sum + o.total, 0);
const onTime = orders.filter(o => o.deliveryDate <= o.promisedDate).length;
const late = totalOrders - onTime;
// Calcular scores
const priceScore = this.calculatePriceScore(orders);
const deliveryScore = (onTime / totalOrders) * 100;
const qualityScore = this.calculateQualityScore(supplierId);
const overallRating = (
priceScore * 0.30 +
deliveryScore * 0.25 +
qualityScore * 0.25 +
80 * 0.10 + // serviceScore
85 * 0.10 // paymentScore
);
await this.ratingRepo.upsert({
supplierId,
evaluationPeriod: period,
priceScore,
deliveryScore,
qualityScore,
overallRating,
totalOrders,
totalAmount,
onTimeDeliveries: onTime,
lateDeliveries: late,
}, ['supplierId', 'evaluationPeriod']);
// Actualizar rating del proveedor
await this.supplierRepo.update(supplierId, { rating: overallRating });
}
async compareQuotes(rfqId: string): Promise<any> {
const quotes = await this.quoteRepo.find({
where: { rfqId, status: 'submitted' },
relations: ['supplier'],
});
const comparison = quotes.map(q => ({
quote: q,
supplier: q.supplier,
score: this.calculateQuoteScore(q),
}));
comparison.sort((a, b) => b.score - a.score);
return {
quotes: comparison,
recommended: comparison[0],
};
}
private calculateQuoteScore(quote: any): number {
const priceScore = 100 - ((quote.total - minTotal) / minTotal * 100);
const supplierScore = quote.supplier.rating;
const deliveryScore = quote.deliveryDate <= requiredDate ? 100 : 50;
return priceScore * 0.40 + supplierScore * 0.35 + deliveryScore * 0.25;
}
}
Estado: ✅ Ready for Implementation