From 63013bb2f42851083ee0f51046179b113e289061 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 08:40:36 -0600 Subject: [PATCH] [MAI-004] feat(purchase): Add construction extensions for purchase orders and suppliers - Add PurchaseOrderConstruction entity for delivery and quality tracking - Add SupplierConstruction entity for ratings, specialties and credit terms - Add PurchaseOrderConstructionService with reception and quality workflows - Add SupplierConstructionService with evaluation and statistics features - Update entities and services index files Co-Authored-By: Claude Opus 4.5 --- src/modules/purchase/entities/index.ts | 2 + .../purchase-order-construction.entity.ts | 114 ++++++ .../entities/supplier-construction.entity.ts | 130 ++++++ src/modules/purchase/services/index.ts | 2 + .../purchase-order-construction.service.ts | 326 +++++++++++++++ .../services/supplier-construction.service.ts | 378 ++++++++++++++++++ 6 files changed, 952 insertions(+) create mode 100644 src/modules/purchase/entities/purchase-order-construction.entity.ts create mode 100644 src/modules/purchase/entities/supplier-construction.entity.ts create mode 100644 src/modules/purchase/services/purchase-order-construction.service.ts create mode 100644 src/modules/purchase/services/supplier-construction.service.ts diff --git a/src/modules/purchase/entities/index.ts b/src/modules/purchase/entities/index.ts index 9a3adf5..129f99c 100644 --- a/src/modules/purchase/entities/index.ts +++ b/src/modules/purchase/entities/index.ts @@ -5,6 +5,8 @@ * Extensiones de compras para construcción (MAI-004) */ +export * from './purchase-order-construction.entity'; +export * from './supplier-construction.entity'; export * from './comparativo-cotizaciones.entity'; export * from './comparativo-proveedor.entity'; export * from './comparativo-producto.entity'; diff --git a/src/modules/purchase/entities/purchase-order-construction.entity.ts b/src/modules/purchase/entities/purchase-order-construction.entity.ts new file mode 100644 index 0000000..e2ab173 --- /dev/null +++ b/src/modules/purchase/entities/purchase-order-construction.entity.ts @@ -0,0 +1,114 @@ +/** + * PurchaseOrderConstruction Entity + * Extensión de órdenes de compra para construcción + * + * @module Purchase (MAI-004) + * @table purchase.purchase_order_construction + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { RequisicionObra } from '../../inventory/entities/requisicion-obra.entity'; + +@Entity({ schema: 'purchase', name: 'purchase_order_construction' }) +@Index(['tenantId']) +@Index(['purchaseOrderId'], { unique: true }) +@Index(['fraccionamientoId']) +@Index(['requisicionId']) +export class PurchaseOrderConstruction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // FK a purchase.purchase_orders (ERP Core) + @Column({ name: 'purchase_order_id', type: 'uuid' }) + purchaseOrderId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ name: 'requisicion_id', type: 'uuid', nullable: true }) + requisicionId: string; + + // Delivery information + @Column({ name: 'delivery_location', type: 'varchar', length: 255, nullable: true }) + deliveryLocation: string; + + @Column({ name: 'delivery_contact', type: 'varchar', length: 100, nullable: true }) + deliveryContact: string; + + @Column({ name: 'delivery_phone', type: 'varchar', length: 20, nullable: true }) + deliveryPhone: string; + + // Reception + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedById: string; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt: Date; + + // Quality check + @Column({ name: 'quality_approved', type: 'boolean', nullable: true }) + qualityApproved: boolean; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes: string; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento, { nullable: true }) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => RequisicionObra, { nullable: true }) + @JoinColumn({ name: 'requisicion_id' }) + requisicion: RequisicionObra; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'received_by' }) + receivedBy: User; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; +} diff --git a/src/modules/purchase/entities/supplier-construction.entity.ts b/src/modules/purchase/entities/supplier-construction.entity.ts new file mode 100644 index 0000000..6104a47 --- /dev/null +++ b/src/modules/purchase/entities/supplier-construction.entity.ts @@ -0,0 +1,130 @@ +/** + * SupplierConstruction Entity + * Extensión de proveedores para construcción + * + * @module Purchase (MAI-004) + * @table purchase.supplier_construction + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'purchase', name: 'supplier_construction' }) +@Index(['tenantId']) +@Index(['supplierId'], { unique: true }) +@Index(['overallRating']) +export class SupplierConstruction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // FK a purchase.suppliers (ERP Core) + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + // Supplier type flags + @Column({ name: 'is_materials_supplier', type: 'boolean', default: false }) + isMaterialsSupplier: boolean; + + @Column({ name: 'is_services_supplier', type: 'boolean', default: false }) + isServicesSupplier: boolean; + + @Column({ name: 'is_equipment_supplier', type: 'boolean', default: false }) + isEquipmentSupplier: boolean; + + @Column({ type: 'text', array: true, nullable: true }) + specialties: string[]; + + // Ratings (1.00 - 5.00) + @Column({ name: 'quality_rating', type: 'decimal', precision: 3, scale: 2, nullable: true }) + qualityRating: number; + + @Column({ name: 'delivery_rating', type: 'decimal', precision: 3, scale: 2, nullable: true }) + deliveryRating: number; + + @Column({ name: 'price_rating', type: 'decimal', precision: 3, scale: 2, nullable: true }) + priceRating: number; + + // Overall rating (computed in DB, but we can calculate in code too) + @Column({ + name: 'overall_rating', + type: 'decimal', + precision: 3, + scale: 2, + nullable: true, + insert: false, + update: false, + }) + overallRating: number; + + @Column({ name: 'last_evaluation_date', type: 'date', nullable: true }) + lastEvaluationDate: Date; + + // Credit terms + @Column({ name: 'credit_limit', type: 'decimal', precision: 14, scale: 2, nullable: true }) + creditLimit: number; + + @Column({ name: 'payment_days', type: 'int', default: 30 }) + paymentDays: number; + + // Documents status + @Column({ name: 'has_valid_documents', type: 'boolean', default: false }) + hasValidDocuments: boolean; + + @Column({ name: 'documents_expiry_date', type: 'date', nullable: true }) + documentsExpiryDate: Date; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; + + // Computed property for overall rating + calculateOverallRating(): number { + const ratings = [this.qualityRating, this.deliveryRating, this.priceRating].filter( + (r) => r !== null && r !== undefined + ); + if (ratings.length === 0) return 0; + return ratings.reduce((sum, r) => sum + Number(r), 0) / ratings.length; + } +} diff --git a/src/modules/purchase/services/index.ts b/src/modules/purchase/services/index.ts index 96280d7..d19f940 100644 --- a/src/modules/purchase/services/index.ts +++ b/src/modules/purchase/services/index.ts @@ -4,3 +4,5 @@ */ export * from './comparativo.service'; +export * from './purchase-order-construction.service'; +export * from './supplier-construction.service'; diff --git a/src/modules/purchase/services/purchase-order-construction.service.ts b/src/modules/purchase/services/purchase-order-construction.service.ts new file mode 100644 index 0000000..5610af1 --- /dev/null +++ b/src/modules/purchase/services/purchase-order-construction.service.ts @@ -0,0 +1,326 @@ +/** + * PurchaseOrderConstructionService - Servicio de extensión de órdenes de compra + * + * Gestión de datos adicionales de OC para construcción. + * + * @module Purchase (MAI-004) + */ + +import { Repository } from 'typeorm'; +import { PurchaseOrderConstruction } from '../entities/purchase-order-construction.entity'; + +export interface CreatePurchaseOrderConstructionDto { + purchaseOrderId: string; + fraccionamientoId?: string; + requisicionId?: string; + deliveryLocation?: string; + deliveryContact?: string; + deliveryPhone?: string; +} + +export interface UpdatePurchaseOrderConstructionDto { + fraccionamientoId?: string; + requisicionId?: string; + deliveryLocation?: string; + deliveryContact?: string; + deliveryPhone?: string; +} + +export interface RegisterReceptionDto { + receivedById: string; + qualityApproved?: boolean; + qualityNotes?: string; +} + +export interface PurchaseOrderConstructionFilters { + fraccionamientoId?: string; + requisicionId?: string; + qualityApproved?: boolean; + receivedFrom?: Date; + receivedTo?: Date; + page?: number; + limit?: number; +} + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class PurchaseOrderConstructionService { + constructor( + private readonly repository: Repository, + ) {} + + async findAll( + ctx: ServiceContext, + filters: PurchaseOrderConstructionFilters = {}, + ): Promise<{ data: PurchaseOrderConstruction[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('poc') + .leftJoinAndSelect('poc.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('poc.requisicion', 'requisicion') + .leftJoinAndSelect('poc.receivedBy', 'receivedBy') + .where('poc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('poc.deleted_at IS NULL'); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('poc.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.requisicionId) { + queryBuilder.andWhere('poc.requisicion_id = :requisicionId', { + requisicionId: filters.requisicionId, + }); + } + + if (filters.qualityApproved !== undefined) { + queryBuilder.andWhere('poc.quality_approved = :qualityApproved', { + qualityApproved: filters.qualityApproved, + }); + } + + if (filters.receivedFrom) { + queryBuilder.andWhere('poc.received_at >= :receivedFrom', { + receivedFrom: filters.receivedFrom, + }); + } + + if (filters.receivedTo) { + queryBuilder.andWhere('poc.received_at <= :receivedTo', { + receivedTo: filters.receivedTo, + }); + } + + const [data, total] = await queryBuilder + .orderBy('poc.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['fraccionamiento', 'requisicion', 'receivedBy', 'createdBy'], + }); + } + + async findByPurchaseOrderId( + ctx: ServiceContext, + purchaseOrderId: string, + ): Promise { + return this.repository.findOne({ + where: { + purchaseOrderId, + tenantId: ctx.tenantId, + }, + relations: ['fraccionamiento', 'requisicion'], + }); + } + + async create( + ctx: ServiceContext, + dto: CreatePurchaseOrderConstructionDto, + ): Promise { + // Check if extension already exists for this purchase order + const existing = await this.findByPurchaseOrderId(ctx, dto.purchaseOrderId); + if (existing) { + throw new Error('Construction extension already exists for this purchase order'); + } + + const extension = this.repository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + purchaseOrderId: dto.purchaseOrderId, + fraccionamientoId: dto.fraccionamientoId, + requisicionId: dto.requisicionId, + deliveryLocation: dto.deliveryLocation, + deliveryContact: dto.deliveryContact, + deliveryPhone: dto.deliveryPhone, + }); + + return this.repository.save(extension); + } + + async update( + ctx: ServiceContext, + id: string, + dto: UpdatePurchaseOrderConstructionDto, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + // Cannot update if already received + if (extension.receivedAt) { + throw new Error('Cannot update extension after order has been received'); + } + + Object.assign(extension, { + ...dto, + updatedById: ctx.userId, + }); + + return this.repository.save(extension); + } + + async registerReception( + ctx: ServiceContext, + id: string, + dto: RegisterReceptionDto, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + if (extension.receivedAt) { + throw new Error('Order has already been received'); + } + + extension.receivedById = dto.receivedById; + extension.receivedAt = new Date(); + if (dto.qualityApproved !== undefined) { + extension.qualityApproved = dto.qualityApproved; + } + if (dto.qualityNotes) { + extension.qualityNotes = dto.qualityNotes; + } + if (ctx.userId) { + extension.updatedById = ctx.userId; + } + + return this.repository.save(extension); + } + + async updateQualityStatus( + ctx: ServiceContext, + id: string, + approved: boolean, + notes?: string, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + if (!extension.receivedAt) { + throw new Error('Cannot update quality status before reception'); + } + + extension.qualityApproved = approved; + if (notes) { + extension.qualityNotes = notes; + } + if (ctx.userId) { + extension.updatedById = ctx.userId; + } + + return this.repository.save(extension); + } + + async findPendingReception( + ctx: ServiceContext, + fraccionamientoId?: string, + ): Promise { + const queryBuilder = this.repository + .createQueryBuilder('poc') + .leftJoinAndSelect('poc.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('poc.requisicion', 'requisicion') + .where('poc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('poc.deleted_at IS NULL') + .andWhere('poc.received_at IS NULL'); + + if (fraccionamientoId) { + queryBuilder.andWhere('poc.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId, + }); + } + + return queryBuilder.orderBy('poc.created_at', 'ASC').getMany(); + } + + async findPendingQualityApproval(ctx: ServiceContext): Promise { + return this.repository + .createQueryBuilder('poc') + .leftJoinAndSelect('poc.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('poc.receivedBy', 'receivedBy') + .where('poc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('poc.deleted_at IS NULL') + .andWhere('poc.received_at IS NOT NULL') + .andWhere('poc.quality_approved IS NULL') + .orderBy('poc.received_at', 'ASC') + .getMany(); + } + + async getReceptionStatistics( + ctx: ServiceContext, + fraccionamientoId?: string, + dateFrom?: Date, + dateTo?: Date, + ): Promise<{ + totalOrders: number; + receivedOrders: number; + pendingOrders: number; + qualityApproved: number; + qualityRejected: number; + qualityPending: number; + }> { + const queryBuilder = this.repository + .createQueryBuilder('poc') + .where('poc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('poc.deleted_at IS NULL'); + + if (fraccionamientoId) { + queryBuilder.andWhere('poc.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId, + }); + } + + if (dateFrom) { + queryBuilder.andWhere('poc.created_at >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('poc.created_at <= :dateTo', { dateTo }); + } + + const all = await queryBuilder.getMany(); + + const received = all.filter((o) => o.receivedAt); + const pending = all.filter((o) => !o.receivedAt); + const qualityApproved = received.filter((o) => o.qualityApproved === true); + const qualityRejected = received.filter((o) => o.qualityApproved === false); + const qualityPending = received.filter((o) => o.qualityApproved === null); + + return { + totalOrders: all.length, + receivedOrders: received.length, + pendingOrders: pending.length, + qualityApproved: qualityApproved.length, + qualityRejected: qualityRejected.length, + qualityPending: qualityPending.length, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), deletedById: ctx.userId }, + ); + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/purchase/services/supplier-construction.service.ts b/src/modules/purchase/services/supplier-construction.service.ts new file mode 100644 index 0000000..020ed1b --- /dev/null +++ b/src/modules/purchase/services/supplier-construction.service.ts @@ -0,0 +1,378 @@ +/** + * SupplierConstructionService - Servicio de extensión de proveedores + * + * Gestión de datos adicionales de proveedores para construcción. + * Incluye evaluaciones, ratings y calificaciones. + * + * @module Purchase (MAI-004) + */ + +import { Repository } from 'typeorm'; +import { SupplierConstruction } from '../entities/supplier-construction.entity'; + +export interface CreateSupplierConstructionDto { + supplierId: string; + isMaterialsSupplier?: boolean; + isServicesSupplier?: boolean; + isEquipmentSupplier?: boolean; + specialties?: string[]; + creditLimit?: number; + paymentDays?: number; + hasValidDocuments?: boolean; + documentsExpiryDate?: Date; +} + +export interface UpdateSupplierConstructionDto { + isMaterialsSupplier?: boolean; + isServicesSupplier?: boolean; + isEquipmentSupplier?: boolean; + specialties?: string[]; + creditLimit?: number; + paymentDays?: number; + hasValidDocuments?: boolean; + documentsExpiryDate?: Date; +} + +export interface EvaluateSupplierDto { + qualityRating: number; + deliveryRating: number; + priceRating: number; +} + +export interface SupplierConstructionFilters { + isMaterialsSupplier?: boolean; + isServicesSupplier?: boolean; + isEquipmentSupplier?: boolean; + minRating?: number; + hasValidDocuments?: boolean; + specialty?: string; + page?: number; + limit?: number; +} + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export class SupplierConstructionService { + constructor( + private readonly repository: Repository, + ) {} + + async findAll( + ctx: ServiceContext, + filters: SupplierConstructionFilters = {}, + ): Promise<{ data: SupplierConstruction[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL'); + + if (filters.isMaterialsSupplier !== undefined) { + queryBuilder.andWhere('sc.is_materials_supplier = :isMaterials', { + isMaterials: filters.isMaterialsSupplier, + }); + } + + if (filters.isServicesSupplier !== undefined) { + queryBuilder.andWhere('sc.is_services_supplier = :isServices', { + isServices: filters.isServicesSupplier, + }); + } + + if (filters.isEquipmentSupplier !== undefined) { + queryBuilder.andWhere('sc.is_equipment_supplier = :isEquipment', { + isEquipment: filters.isEquipmentSupplier, + }); + } + + if (filters.minRating !== undefined) { + queryBuilder.andWhere('sc.overall_rating >= :minRating', { + minRating: filters.minRating, + }); + } + + if (filters.hasValidDocuments !== undefined) { + queryBuilder.andWhere('sc.has_valid_documents = :hasValid', { + hasValid: filters.hasValidDocuments, + }); + } + + if (filters.specialty) { + queryBuilder.andWhere(':specialty = ANY(sc.specialties)', { + specialty: filters.specialty, + }); + } + + const [data, total] = await queryBuilder + .orderBy('sc.overall_rating', 'DESC', 'NULLS LAST') + .addOrderBy('sc.created_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['createdBy', 'updatedBy'], + }); + } + + async findBySupplierId(ctx: ServiceContext, supplierId: string): Promise { + return this.repository.findOne({ + where: { + supplierId, + tenantId: ctx.tenantId, + }, + }); + } + + async create( + ctx: ServiceContext, + dto: CreateSupplierConstructionDto, + ): Promise { + // Check if extension already exists for this supplier + const existing = await this.findBySupplierId(ctx, dto.supplierId); + if (existing) { + throw new Error('Construction extension already exists for this supplier'); + } + + const extension = this.repository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + supplierId: dto.supplierId, + isMaterialsSupplier: dto.isMaterialsSupplier ?? false, + isServicesSupplier: dto.isServicesSupplier ?? false, + isEquipmentSupplier: dto.isEquipmentSupplier ?? false, + specialties: dto.specialties, + creditLimit: dto.creditLimit, + paymentDays: dto.paymentDays ?? 30, + hasValidDocuments: dto.hasValidDocuments ?? false, + documentsExpiryDate: dto.documentsExpiryDate, + }); + + return this.repository.save(extension); + } + + async update( + ctx: ServiceContext, + id: string, + dto: UpdateSupplierConstructionDto, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + Object.assign(extension, { + ...dto, + updatedById: ctx.userId, + }); + + return this.repository.save(extension); + } + + async evaluate( + ctx: ServiceContext, + id: string, + dto: EvaluateSupplierDto, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + // Validate ratings (1.00 - 5.00) + const ratings = [dto.qualityRating, dto.deliveryRating, dto.priceRating]; + for (const rating of ratings) { + if (rating < 1 || rating > 5) { + throw new Error('Ratings must be between 1.00 and 5.00'); + } + } + + extension.qualityRating = dto.qualityRating; + extension.deliveryRating = dto.deliveryRating; + extension.priceRating = dto.priceRating; + extension.lastEvaluationDate = new Date(); + if (ctx.userId) { + extension.updatedById = ctx.userId; + } + + return this.repository.save(extension); + } + + async updateDocumentStatus( + ctx: ServiceContext, + id: string, + hasValidDocuments: boolean, + expiryDate?: Date, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + extension.hasValidDocuments = hasValidDocuments; + if (expiryDate) { + extension.documentsExpiryDate = expiryDate; + } + if (ctx.userId) { + extension.updatedById = ctx.userId; + } + + return this.repository.save(extension); + } + + async updateCreditTerms( + ctx: ServiceContext, + id: string, + creditLimit: number, + paymentDays: number, + ): Promise { + const extension = await this.findById(ctx, id); + if (!extension) { + return null; + } + + if (creditLimit < 0) { + throw new Error('Credit limit cannot be negative'); + } + + if (paymentDays < 0) { + throw new Error('Payment days cannot be negative'); + } + + extension.creditLimit = creditLimit; + extension.paymentDays = paymentDays; + if (ctx.userId) { + extension.updatedById = ctx.userId; + } + + return this.repository.save(extension); + } + + async findTopRated(ctx: ServiceContext, limit: number = 10): Promise { + return this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL') + .andWhere('sc.overall_rating IS NOT NULL') + .orderBy('sc.overall_rating', 'DESC') + .take(limit) + .getMany(); + } + + async findBySpecialty(ctx: ServiceContext, specialty: string): Promise { + return this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL') + .andWhere(':specialty = ANY(sc.specialties)', { specialty }) + .orderBy('sc.overall_rating', 'DESC', 'NULLS LAST') + .getMany(); + } + + async findMaterialsSuppliers(ctx: ServiceContext): Promise { + return this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL') + .andWhere('sc.is_materials_supplier = true') + .orderBy('sc.overall_rating', 'DESC', 'NULLS LAST') + .getMany(); + } + + async findServicesSuppliers(ctx: ServiceContext): Promise { + return this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL') + .andWhere('sc.is_services_supplier = true') + .orderBy('sc.overall_rating', 'DESC', 'NULLS LAST') + .getMany(); + } + + async findEquipmentSuppliers(ctx: ServiceContext): Promise { + return this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL') + .andWhere('sc.is_equipment_supplier = true') + .orderBy('sc.overall_rating', 'DESC', 'NULLS LAST') + .getMany(); + } + + async findWithExpiringDocuments( + ctx: ServiceContext, + daysAhead: number = 30, + ): Promise { + const expiryThreshold = new Date(); + expiryThreshold.setDate(expiryThreshold.getDate() + daysAhead); + + return this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL') + .andWhere('sc.has_valid_documents = true') + .andWhere('sc.documents_expiry_date IS NOT NULL') + .andWhere('sc.documents_expiry_date <= :threshold', { threshold: expiryThreshold }) + .orderBy('sc.documents_expiry_date', 'ASC') + .getMany(); + } + + async getSupplierStatistics(ctx: ServiceContext): Promise<{ + totalSuppliers: number; + materialsSuppliers: number; + servicesSuppliers: number; + equipmentSuppliers: number; + withValidDocs: number; + withExpiringDocs: number; + averageRating: number; + evaluatedSuppliers: number; + }> { + const queryBuilder = this.repository + .createQueryBuilder('sc') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL'); + + const all = await queryBuilder.getMany(); + + const expiryThreshold = new Date(); + expiryThreshold.setDate(expiryThreshold.getDate() + 30); + + const evaluated = all.filter((s) => s.overallRating !== null); + const totalRating = evaluated.reduce((sum, s) => sum + Number(s.overallRating || 0), 0); + + return { + totalSuppliers: all.length, + materialsSuppliers: all.filter((s) => s.isMaterialsSupplier).length, + servicesSuppliers: all.filter((s) => s.isServicesSupplier).length, + equipmentSuppliers: all.filter((s) => s.isEquipmentSupplier).length, + withValidDocs: all.filter((s) => s.hasValidDocuments).length, + withExpiringDocs: all.filter( + (s) => s.hasValidDocuments && s.documentsExpiryDate && s.documentsExpiryDate <= expiryThreshold, + ).length, + averageRating: evaluated.length > 0 ? totalRating / evaluated.length : 0, + evaluatedSuppliers: evaluated.length, + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), deletedById: ctx.userId }, + ); + return (result.affected ?? 0) > 0; + } +}