erp-construccion/docs/02-definicion-modulos/MAI-004-compras-inventarios/especificaciones/ET-PURCH-001-implementacion-proveedores.md

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