[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)
|
* 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-cotizaciones.entity';
|
||||||
export * from './comparativo-proveedor.entity';
|
export * from './comparativo-proveedor.entity';
|
||||||
export * from './comparativo-producto.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 './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