27 KiB
27 KiB
ET-PURCH-002: Implementación de Requisiciones y Órdenes de Compra
Épica: MAI-004 - Compras e Inventarios Versión: 1.0 Fecha: 2025-11-17
1. Schemas SQL
CREATE SCHEMA IF NOT EXISTS purchases;
-- Tabla: requisitions
CREATE TABLE purchases.requisitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
constructora_id UUID NOT NULL REFERENCES public.constructoras(id),
project_id UUID NOT NULL REFERENCES projects.projects(id),
requested_by UUID NOT NULL REFERENCES auth.users(id),
required_date DATE NOT NULL,
urgency VARCHAR(20) DEFAULT 'normal' CHECK (urgency IN ('normal', 'urgent')),
items JSONB NOT NULL,
/* Estructura items:
[{
materialId: UUID,
description: string,
quantity: number,
unit: string,
budgetedPrice: number,
budgetItemId: UUID,
notes: string
}]
*/
justification TEXT,
estimated_total DECIMAL(15,2),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'pending', 'approved', 'rejected', 'quoted', 'ordered')),
approval_flow JSONB,
/* Estructura approval_flow:
[{
level: 1,
approverRole: 'residente',
approverId: UUID,
status: 'approved',
comments: string,
approvedAt: timestamp
}]
*/
rejected_reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_requisitions_project ON purchases.requisitions(project_id);
CREATE INDEX idx_requisitions_status ON purchases.requisitions(status);
CREATE INDEX idx_requisitions_requested_by ON purchases.requisitions(requested_by);
-- Tabla: purchase_orders
CREATE TABLE purchases.purchase_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
constructora_id UUID NOT NULL REFERENCES public.constructoras(id),
supplier_id UUID NOT NULL REFERENCES purchases.suppliers(id),
project_id UUID NOT NULL REFERENCES projects.projects(id),
requisition_id UUID REFERENCES purchases.requisitions(id),
rfq_id UUID REFERENCES purchases.rfqs(id),
quote_id UUID REFERENCES purchases.quotes(id),
order_date DATE DEFAULT CURRENT_DATE,
delivery_date DATE NOT NULL,
delivery_address TEXT NOT NULL,
items JSONB NOT NULL,
/* Estructura items:
[{
materialId: UUID,
description: string,
quantity: number,
unit: string,
unitPrice: number,
subtotal: number,
budgetItemId: UUID
}]
*/
subtotal DECIMAL(15,2) NOT NULL,
tax DECIMAL(15,2) NOT NULL,
total DECIMAL(15,2) NOT NULL,
payment_terms VARCHAR(50),
payment_terms_days INTEGER DEFAULT 30,
early_payment_discount DECIMAL(5,2) DEFAULT 0,
requires_advance BOOLEAN DEFAULT false,
advance_percentage DECIMAL(5,2),
includes_unloading BOOLEAN DEFAULT true,
warranty_days INTEGER DEFAULT 30,
special_conditions TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'sent', 'partially_received', 'received', 'cancelled')),
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMP,
sent_to_supplier_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_purchase_orders_supplier ON purchases.purchase_orders(supplier_id);
CREATE INDEX idx_purchase_orders_project ON purchases.purchase_orders(project_id);
CREATE INDEX idx_purchase_orders_status ON purchases.purchase_orders(status);
CREATE INDEX idx_purchase_orders_delivery ON purchases.purchase_orders(delivery_date);
-- Tabla: purchase_order_receipts
CREATE TABLE purchases.purchase_order_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
purchase_order_id UUID NOT NULL REFERENCES purchases.purchase_orders(id),
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
receipt_date DATE DEFAULT CURRENT_DATE,
received_by UUID NOT NULL REFERENCES auth.users(id),
items JSONB NOT NULL,
/* Estructura items:
[{
poItemId: string,
materialId: UUID,
orderedQuantity: number,
receivedQuantity: number,
acceptedQuantity: number,
rejectedQuantity: number,
rejectionReason: string
}]
*/
delivery_note VARCHAR(50),
transport_company VARCHAR(100),
notes TEXT,
attachments VARCHAR[],
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_receipts_purchase_order ON purchases.purchase_order_receipts(purchase_order_id);
CREATE INDEX idx_receipts_warehouse ON purchases.purchase_order_receipts(warehouse_id);
-- Tabla: invoices
CREATE TABLE purchases.invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
constructora_id UUID NOT NULL REFERENCES public.constructoras(id),
supplier_id UUID NOT NULL REFERENCES purchases.suppliers(id),
purchase_order_id UUID REFERENCES purchases.purchase_orders(id),
invoice_number VARCHAR(50) NOT NULL,
fiscal_uuid VARCHAR(36) UNIQUE,
invoice_date DATE NOT NULL,
subtotal DECIMAL(15,2) NOT NULL,
tax DECIMAL(15,2) NOT NULL,
total DECIMAL(15,2) NOT NULL,
due_date DATE NOT NULL,
early_payment_date DATE,
early_payment_discount DECIMAL(15,2),
xml_file VARCHAR(255),
pdf_file VARCHAR(255),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'scheduled', 'paid', 'rejected')),
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMP,
payment_scheduled_date DATE,
paid_date DATE,
payment_reference VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_invoices_supplier ON purchases.invoices(supplier_id);
CREATE INDEX idx_invoices_po ON purchases.invoices(purchase_order_id);
CREATE INDEX idx_invoices_status ON purchases.invoices(status);
CREATE INDEX idx_invoices_due_date ON purchases.invoices(due_date);
-- Función: Actualizar status de OC al recibir
CREATE OR REPLACE FUNCTION purchases.update_po_status_on_receipt()
RETURNS TRIGGER AS $$
DECLARE
v_total_ordered DECIMAL(15,4);
v_total_received DECIMAL(15,4);
v_item JSONB;
BEGIN
-- Calcular total ordenado vs recibido
SELECT
SUM((item->>'quantity')::DECIMAL),
SUM(
(SELECT SUM((receipt_item->>'receivedQuantity')::DECIMAL)
FROM purchases.purchase_order_receipts por,
JSONB_ARRAY_ELEMENTS(por.items) AS receipt_item
WHERE por.purchase_order_id = NEW.purchase_order_id
AND receipt_item->>'materialId' = item->>'materialId')
)
INTO v_total_ordered, v_total_received
FROM purchases.purchase_orders po,
JSONB_ARRAY_ELEMENTS(po.items) AS item
WHERE po.id = NEW.purchase_order_id;
-- Actualizar status
IF v_total_received >= v_total_ordered THEN
UPDATE purchases.purchase_orders
SET status = 'received', updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.purchase_order_id;
ELSIF v_total_received > 0 THEN
UPDATE purchases.purchase_orders
SET status = 'partially_received', updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.purchase_order_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_po_status
AFTER INSERT ON purchases.purchase_order_receipts
FOR EACH ROW
EXECUTE FUNCTION purchases.update_po_status_on_receipt();
-- Función: Generar código de requisición
CREATE OR REPLACE FUNCTION purchases.generate_requisition_code()
RETURNS TRIGGER AS $$
DECLARE
v_year VARCHAR(4);
v_sequence INTEGER;
BEGIN
v_year := TO_CHAR(CURRENT_DATE, 'YYYY');
SELECT COALESCE(MAX(SUBSTRING(code FROM 9)::INTEGER), 0) + 1
INTO v_sequence
FROM purchases.requisitions
WHERE code LIKE 'REQ-' || v_year || '-%';
NEW.code := 'REQ-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_generate_requisition_code
BEFORE INSERT ON purchases.requisitions
FOR EACH ROW
WHEN (NEW.code IS NULL)
EXECUTE FUNCTION purchases.generate_requisition_code();
-- Función similar para OCs
CREATE OR REPLACE FUNCTION purchases.generate_po_code()
RETURNS TRIGGER AS $$
DECLARE
v_year VARCHAR(4);
v_sequence INTEGER;
BEGIN
v_year := TO_CHAR(CURRENT_DATE, 'YYYY');
SELECT COALESCE(MAX(SUBSTRING(code FROM 8)::INTEGER), 0) + 1
INTO v_sequence
FROM purchases.purchase_orders
WHERE code LIKE 'OC-' || v_year || '-%';
NEW.code := 'OC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_generate_po_code
BEFORE INSERT ON purchases.purchase_orders
FOR EACH ROW
WHEN (NEW.code IS NULL)
EXECUTE FUNCTION purchases.generate_po_code();
2. TypeORM Entities
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('requisitions', { schema: 'purchases' })
export class Requisition {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ name: 'constructora_id' })
constructoraId: string;
@Column({ name: 'project_id' })
projectId: string;
@Column({ name: 'requested_by' })
requestedBy: string;
@Column({ name: 'required_date', type: 'date' })
requiredDate: Date;
@Column({ default: 'normal' })
urgency: 'normal' | 'urgent';
@Column({ type: 'jsonb' })
items: RequisitionItem[];
@Column({ type: 'text', nullable: true })
justification: string;
@Column({ name: 'estimated_total', type: 'decimal', precision: 15, scale: 2 })
estimatedTotal: number;
@Column({ default: 'draft' })
status: 'draft' | 'pending' | 'approved' | 'rejected' | 'quoted' | 'ordered';
@Column({ name: 'approval_flow', type: 'jsonb', nullable: true })
approvalFlow: ApprovalStep[];
@Column({ name: 'rejected_reason', type: 'text', nullable: true })
rejectedReason: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToMany(() => PurchaseOrder, po => po.requisition)
purchaseOrders: PurchaseOrder[];
}
interface RequisitionItem {
materialId: string;
description: string;
quantity: number;
unit: string;
budgetedPrice: number;
budgetItemId: string;
notes?: string;
}
interface ApprovalStep {
level: number;
approverRole: string;
approverId: string;
status: 'pending' | 'approved' | 'rejected';
comments?: string;
approvedAt?: Date;
}
@Entity('purchase_orders', { schema: 'purchases' })
export class PurchaseOrder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ name: 'constructora_id' })
constructoraId: string;
@Column({ name: 'supplier_id' })
supplierId: string;
@Column({ name: 'project_id' })
projectId: string;
@Column({ name: 'requisition_id', nullable: true })
requisitionId: string;
@Column({ name: 'rfq_id', nullable: true })
rfqId: string;
@Column({ name: 'quote_id', nullable: true })
quoteId: string;
@Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' })
orderDate: Date;
@Column({ name: 'delivery_date', type: 'date' })
deliveryDate: Date;
@Column({ name: 'delivery_address', type: 'text' })
deliveryAddress: string;
@Column({ type: 'jsonb' })
items: PurchaseOrderItem[];
@Column({ type: 'decimal', precision: 15, scale: 2 })
subtotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2 })
tax: number;
@Column({ type: 'decimal', precision: 15, scale: 2 })
total: number;
@Column({ name: 'payment_terms', nullable: true })
paymentTerms: string;
@Column({ name: 'payment_terms_days', default: 30 })
paymentTermsDays: number;
@Column({ name: 'early_payment_discount', type: 'decimal', precision: 5, scale: 2, default: 0 })
earlyPaymentDiscount: number;
@Column({ name: 'includes_unloading', default: true })
includesUnloading: boolean;
@Column({ name: 'warranty_days', default: 30 })
warrantyDays: number;
@Column({ name: 'special_conditions', type: 'text', nullable: true })
specialConditions: string;
@Column({ default: 'pending' })
status: 'pending' | 'approved' | 'sent' | 'partially_received' | 'received' | 'cancelled';
@Column({ name: 'approved_by', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', nullable: true })
approvedAt: Date;
@Column({ name: 'sent_to_supplier_at', nullable: true })
sentToSupplierAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => Requisition, req => req.purchaseOrders)
requisition: Requisition;
@OneToMany(() => PurchaseOrderReceipt, receipt => receipt.purchaseOrder)
receipts: PurchaseOrderReceipt[];
@OneToMany(() => Invoice, invoice => invoice.purchaseOrder)
invoices: Invoice[];
}
interface PurchaseOrderItem {
materialId: string;
description: string;
quantity: number;
unit: string;
unitPrice: number;
subtotal: number;
budgetItemId?: string;
}
@Entity('purchase_order_receipts', { schema: 'purchases' })
export class PurchaseOrderReceipt {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ name: 'purchase_order_id' })
purchaseOrderId: string;
@Column({ name: 'warehouse_id' })
warehouseId: string;
@Column({ name: 'receipt_date', type: 'date', default: () => 'CURRENT_DATE' })
receiptDate: Date;
@Column({ name: 'received_by' })
receivedBy: string;
@Column({ type: 'jsonb' })
items: ReceiptItem[];
@Column({ name: 'delivery_note', nullable: true })
deliveryNote: string;
@Column({ name: 'transport_company', nullable: true })
transportCompany: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ type: 'varchar', array: true, default: '{}' })
attachments: string[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => PurchaseOrder, po => po.receipts)
purchaseOrder: PurchaseOrder;
}
interface ReceiptItem {
poItemId: string;
materialId: string;
orderedQuantity: number;
receivedQuantity: number;
acceptedQuantity: number;
rejectedQuantity: number;
rejectionReason?: string;
}
@Entity('invoices', { schema: 'purchases' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 20, unique: true })
code: string;
@Column({ name: 'constructora_id' })
constructoraId: string;
@Column({ name: 'supplier_id' })
supplierId: string;
@Column({ name: 'purchase_order_id', nullable: true })
purchaseOrderId: string;
@Column({ name: 'invoice_number' })
invoiceNumber: string;
@Column({ name: 'fiscal_uuid', unique: true, nullable: true })
fiscalUuid: string;
@Column({ name: 'invoice_date', type: 'date' })
invoiceDate: Date;
@Column({ type: 'decimal', precision: 15, scale: 2 })
subtotal: number;
@Column({ type: 'decimal', precision: 15, scale: 2 })
tax: number;
@Column({ type: 'decimal', precision: 15, scale: 2 })
total: number;
@Column({ name: 'due_date', type: 'date' })
dueDate: Date;
@Column({ name: 'early_payment_date', type: 'date', nullable: true })
earlyPaymentDate: Date;
@Column({ name: 'early_payment_discount', type: 'decimal', precision: 15, scale: 2, nullable: true })
earlyPaymentDiscount: number;
@Column({ name: 'xml_file', nullable: true })
xmlFile: string;
@Column({ name: 'pdf_file', nullable: true })
pdfFile: string;
@Column({ default: 'pending' })
status: 'pending' | 'approved' | 'scheduled' | 'paid' | 'rejected';
@Column({ name: 'approved_by', nullable: true })
approvedBy: string;
@Column({ name: 'approved_at', nullable: true })
approvedAt: Date;
@Column({ name: 'payment_scheduled_date', type: 'date', nullable: true })
paymentScheduledDate: Date;
@Column({ name: 'paid_date', type: 'date', nullable: true })
paidDate: Date;
@Column({ name: 'payment_reference', nullable: true })
paymentReference: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => PurchaseOrder, po => po.invoices)
purchaseOrder: PurchaseOrder;
}
3. Services (Métodos Clave)
@Injectable()
export class RequisitionService {
constructor(
@InjectRepository(Requisition)
private requisitionRepo: Repository<Requisition>,
private budgetService: BudgetService,
private eventEmitter: EventEmitter2,
) {}
async createRequisition(dto: CreateRequisitionDto, userId: string): Promise<Requisition> {
// Validar contra presupuesto
const validationResults = await Promise.all(
dto.items.map(item => this.validateAgainstBudget(dto.projectId, item))
);
const hasErrors = validationResults.some(r => !r.valid);
if (hasErrors) {
throw new BadRequestException('Algunos items exceden el presupuesto disponible');
}
const estimatedTotal = dto.items.reduce(
(sum, item) => sum + (item.quantity * item.budgetedPrice),
0
);
const requisition = this.requisitionRepo.create({
...dto,
requestedBy: userId,
estimatedTotal,
status: 'draft',
});
return await this.requisitionRepo.save(requisition);
}
async submitForApproval(requisitionId: string): Promise<Requisition> {
const requisition = await this.requisitionRepo.findOneOrFail({
where: { id: requisitionId },
});
// Determinar flujo de aprobación según monto
const approvalFlow = this.determineApprovalFlow(requisition.estimatedTotal);
requisition.status = 'pending';
requisition.approvalFlow = approvalFlow;
await this.requisitionRepo.save(requisition);
// Notificar al primer aprobador
this.eventEmitter.emit('requisition.pending_approval', {
requisitionId,
approverId: approvalFlow[0].approverId,
});
return requisition;
}
private determineApprovalFlow(amount: number): ApprovalStep[] {
const flow: ApprovalStep[] = [];
// Nivel 1: Residente (siempre)
flow.push({
level: 1,
approverRole: 'residente',
approverId: null, // Se asigna dinámicamente
status: 'pending',
});
// Nivel 2: Gerente Compras (>= $50K)
if (amount >= 50000) {
flow.push({
level: 2,
approverRole: 'gerente_compras',
approverId: null,
status: 'pending',
});
}
// Nivel 3: Director Proyectos (>= $200K)
if (amount >= 200000) {
flow.push({
level: 3,
approverRole: 'director_proyectos',
approverId: null,
status: 'pending',
});
}
// Nivel 4: Dirección General (>= $500K)
if (amount >= 500000) {
flow.push({
level: 4,
approverRole: 'direccion_general',
approverId: null,
status: 'pending',
});
}
return flow;
}
async approveStep(requisitionId: string, userId: string, level: number, comments?: string): Promise<Requisition> {
const requisition = await this.requisitionRepo.findOneOrFail({
where: { id: requisitionId },
});
const step = requisition.approvalFlow.find(s => s.level === level);
if (!step) {
throw new BadRequestException('Nivel de aprobación no válido');
}
step.status = 'approved';
step.approverId = userId;
step.approvedAt = new Date();
step.comments = comments;
// Si es el último nivel, marcar como aprobado
const allApproved = requisition.approvalFlow.every(s => s.status === 'approved');
if (allApproved) {
requisition.status = 'approved';
this.eventEmitter.emit('requisition.approved', { requisitionId });
} else {
// Notificar al siguiente aprobador
const nextStep = requisition.approvalFlow.find(s => s.status === 'pending');
if (nextStep) {
this.eventEmitter.emit('requisition.pending_approval', {
requisitionId,
approverId: nextStep.approverId,
});
}
}
return await this.requisitionRepo.save(requisition);
}
private async validateAgainstBudget(projectId: string, item: RequisitionItem) {
const budgetItem = await this.budgetService.getBudgetItem(item.budgetItemId);
const exercised = await this.budgetService.getExercisedAmount(item.budgetItemId);
const available = budgetItem.budgetedAmount - exercised;
const requestedAmount = item.quantity * item.budgetedPrice;
return {
valid: requestedAmount <= available,
available,
requested: requestedAmount,
};
}
}
@Injectable()
export class PurchaseOrderService {
constructor(
@InjectRepository(PurchaseOrder)
private poRepo: Repository<PurchaseOrder>,
private pdfService: PdfService,
private emailService: EmailService,
) {}
async createFromQuote(quoteId: string, userId: string): Promise<PurchaseOrder> {
const quote = await this.quoteService.findOne(quoteId, ['rfq', 'supplier']);
const po = this.poRepo.create({
supplierId: quote.supplierId,
projectId: quote.rfq.projectId,
requisitionId: quote.rfq.requisitionId,
rfqId: quote.rfqId,
quoteId: quote.id,
deliveryDate: quote.deliveryDate,
deliveryAddress: quote.rfq.deliveryAddress,
items: quote.items,
subtotal: quote.subtotal,
tax: quote.tax,
total: quote.total,
paymentTerms: quote.paymentTerms,
status: 'pending',
});
return await this.poRepo.save(po);
}
async approve(poId: string, userId: string): Promise<PurchaseOrder> {
const po = await this.poRepo.findOneOrFail({ where: { id: poId } });
po.status = 'approved';
po.approvedBy = userId;
po.approvedAt = new Date();
return await this.poRepo.save(po);
}
async sendToSupplier(poId: string): Promise<void> {
const po = await this.poRepo.findOneOrFail({
where: { id: poId },
relations: ['supplier'],
});
// Generar PDF
const pdfBuffer = await this.pdfService.generatePO(po);
// Enviar email
await this.emailService.send({
to: po.supplier.contactEmail,
subject: `Orden de Compra ${po.code}`,
body: `Estimado proveedor,\n\nAdjuntamos orden de compra ${po.code}...`,
attachments: [{
filename: `${po.code}.pdf`,
content: pdfBuffer,
}],
});
po.sentToSupplierAt = new Date();
po.status = 'sent';
await this.poRepo.save(po);
}
async registerReceipt(dto: CreateReceiptDto, userId: string): Promise<PurchaseOrderReceipt> {
const receipt = this.receiptRepo.create({
...dto,
receivedBy: userId,
});
await this.receiptRepo.save(receipt);
// El trigger actualiza automáticamente el status de la OC
// Crear movimiento de inventario
this.eventEmitter.emit('inventory.entry', {
warehouseId: dto.warehouseId,
sourceType: 'purchase_order',
sourceId: dto.purchaseOrderId,
items: dto.items.map(item => ({
materialId: item.materialId,
quantity: item.acceptedQuantity,
unitCost: this.getUnitCostFromPO(dto.purchaseOrderId, item.materialId),
})),
});
return receipt;
}
}
@Injectable()
export class InvoiceService {
constructor(
@InjectRepository(Invoice)
private invoiceRepo: Repository<Invoice>,
private eventEmitter: EventEmitter2,
) {}
async create(dto: CreateInvoiceDto): Promise<Invoice> {
const po = await this.poRepo.findOneOrFail({
where: { id: dto.purchaseOrderId }
});
const dueDate = new Date(dto.invoiceDate);
dueDate.setDate(dueDate.getDate() + po.paymentTermsDays);
let earlyPaymentDate = null;
let earlyPaymentDiscount = null;
if (po.earlyPaymentDiscount > 0) {
earlyPaymentDate = new Date(dto.invoiceDate);
earlyPaymentDate.setDate(earlyPaymentDate.getDate() + 10);
earlyPaymentDiscount = dto.total * (po.earlyPaymentDiscount / 100);
}
const invoice = this.invoiceRepo.create({
...dto,
dueDate,
earlyPaymentDate,
earlyPaymentDiscount,
status: 'pending',
});
return await this.invoiceRepo.save(invoice);
}
async getUpcomingPayments(days: number = 7): Promise<Invoice[]> {
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + days);
return await this.invoiceRepo.find({
where: {
status: In(['approved', 'scheduled']),
dueDate: LessThanOrEqual(targetDate),
},
order: { dueDate: 'ASC' },
});
}
}
4. React Components (Ejemplos Clave)
// RequisitionForm.tsx
export const RequisitionForm: React.FC = () => {
const [items, setItems] = useState<RequisitionItem[]>([]);
const handleAddItem = () => {
setItems([...items, {
materialId: '',
description: '',
quantity: 0,
unit: '',
budgetedPrice: 0,
budgetItemId: '',
}]);
};
const validateItem = async (item: RequisitionItem) => {
const response = await api.post('/requisitions/validate-budget', item);
return response.data;
};
return (
<form onSubmit={handleSubmit}>
<Select label="Proyecto" {...projectField} />
<DatePicker label="Fecha Requerida" {...requiredDateField} />
<div className="items-section">
{items.map((item, index) => (
<RequisitionItemRow
key={index}
item={item}
onChange={(updated) => updateItem(index, updated)}
onValidate={() => validateItem(item)}
/>
))}
<Button onClick={handleAddItem}>+ Agregar Material</Button>
</div>
<Textarea label="Justificación" {...justificationField} />
<div className="actions">
<Button variant="secondary" onClick={saveDraft}>
Guardar Borrador
</Button>
<Button type="submit">Enviar a Aprobación</Button>
</div>
</form>
);
};
// PurchaseOrderPDF.tsx
export const generatePOPdf = (po: PurchaseOrder) => {
const doc = new jsPDF();
// Header
doc.setFontSize(18);
doc.text('ORDEN DE COMPRA', 105, 20, { align: 'center' });
doc.setFontSize(12);
doc.text(`Folio: ${po.code}`, 105, 28, { align: 'center' });
// Supplier info
doc.setFontSize(10);
doc.text(`Proveedor: ${po.supplier.legalName}`, 20, 45);
doc.text(`RFC: ${po.supplier.taxId}`, 20, 52);
doc.text(`Contacto: ${po.supplier.contactName}`, 20, 59);
// Delivery info
doc.text(`Entregar en: ${po.deliveryAddress}`, 20, 70);
doc.text(`Fecha entrega: ${format(po.deliveryDate, 'dd/MM/yyyy')}`, 20, 77);
// Items table
autoTable(doc, {
startY: 90,
head: [['#', 'Descripción', 'Cant', 'U', 'P.U.', 'Total']],
body: po.items.map((item, i) => [
i + 1,
item.description,
formatNumber(item.quantity),
item.unit,
formatCurrency(item.unitPrice),
formatCurrency(item.subtotal),
]),
});
// Totals
const finalY = (doc as any).lastAutoTable.finalY;
doc.text(`Subtotal: ${formatCurrency(po.subtotal)}`, 150, finalY + 10);
doc.text(`IVA 16%: ${formatCurrency(po.tax)}`, 150, finalY + 17);
doc.setFontSize(12);
doc.text(`TOTAL: ${formatCurrency(po.total)}`, 150, finalY + 24);
return doc;
};
Estado: ✅ Ready for Implementation