[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:
parent
22b9692e3a
commit
63013bb2f4
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
130
src/modules/purchase/entities/supplier-construction.entity.ts
Normal file
130
src/modules/purchase/entities/supplier-construction.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -4,3 +4,5 @@
|
||||
*/
|
||||
|
||||
export * from './comparativo.service';
|
||||
export * from './purchase-order-construction.service';
|
||||
export * from './supplier-construction.service';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
378
src/modules/purchase/services/supplier-construction.service.ts
Normal file
378
src/modules/purchase/services/supplier-construction.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user