[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 08:40:36 -06:00
parent 22b9692e3a
commit 63013bb2f4
6 changed files with 952 additions and 0 deletions

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -4,3 +4,5 @@
*/
export * from './comparativo.service';
export * from './purchase-order-construction.service';
export * from './supplier-construction.service';

View File

@ -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<PurchaseOrderConstruction>,
) {}
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<PurchaseOrderConstruction | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
},
relations: ['fraccionamiento', 'requisicion', 'receivedBy', 'createdBy'],
});
}
async findByPurchaseOrderId(
ctx: ServiceContext,
purchaseOrderId: string,
): Promise<PurchaseOrderConstruction | null> {
return this.repository.findOne({
where: {
purchaseOrderId,
tenantId: ctx.tenantId,
},
relations: ['fraccionamiento', 'requisicion'],
});
}
async create(
ctx: ServiceContext,
dto: CreatePurchaseOrderConstructionDto,
): Promise<PurchaseOrderConstruction> {
// 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<PurchaseOrderConstruction | null> {
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<PurchaseOrderConstruction | null> {
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<PurchaseOrderConstruction | null> {
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<PurchaseOrderConstruction[]> {
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<PurchaseOrderConstruction[]> {
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<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId },
{ deletedAt: new Date(), deletedById: ctx.userId },
);
return (result.affected ?? 0) > 0;
}
}

View File

@ -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<SupplierConstruction>,
) {}
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<SupplierConstruction | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
},
relations: ['createdBy', 'updatedBy'],
});
}
async findBySupplierId(ctx: ServiceContext, supplierId: string): Promise<SupplierConstruction | null> {
return this.repository.findOne({
where: {
supplierId,
tenantId: ctx.tenantId,
},
});
}
async create(
ctx: ServiceContext,
dto: CreateSupplierConstructionDto,
): Promise<SupplierConstruction> {
// 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<SupplierConstruction | null> {
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<SupplierConstruction | null> {
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<SupplierConstruction | null> {
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<SupplierConstruction | null> {
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<SupplierConstruction[]> {
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<SupplierConstruction[]> {
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<SupplierConstruction[]> {
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<SupplierConstruction[]> {
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<SupplierConstruction[]> {
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<SupplierConstruction[]> {
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<boolean> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId },
{ deletedAt: new Date(), deletedById: ctx.userId },
);
return (result.affected ?? 0) > 0;
}
}