from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.contrib import messages from django.http import JsonResponse, HttpResponse from django.utils import timezone from django.db.models import Sum, Count, Q from decimal import Decimal from datetime import date from .models import ( ItemCatalog, PurchaseRequisition, RequisitionItem, PurchaseOrder, PurchaseOrderItem, GoodsReceipt, ThreeWayMatch, RequisitionStatusEnum, PurchaseOrderStatusEnum, MatchStatusEnum ) from .forms import ( ItemCatalogForm, PurchaseRequisitionForm, PurchaseOrderForm, GoodsReceiptForm, ThreeWayMatchForm ) from .services.matching import ThreeWayMatchingEngine from expenses.models import Project, Vendor, Category # ----------------- CATALOG VIEWS ----------------- @login_required def catalog_list(request): query = request.GET.get('q', '') cat_id = request.GET.get('category', '') items = ItemCatalog.objects.filter(is_active=True).select_related('category', 'preferred_vendor') if query: items = items.filter(Q(name__icontains=query) | Q(code__icontains=query) | Q(specifications__icontains=query)) if cat_id: items = items.filter(category_id=cat_id) categories = Category.objects.all() return render(request, 'procurement/catalog_list.html', { 'items': items, 'categories': categories, 'selected_cat': cat_id, 'query': query, }) @login_required def catalog_create(request): if request.method == 'POST': form = ItemCatalogForm(request.POST) if form.is_valid(): item = form.save() messages.success(request, f"Ítem {item.code} - {item.name} agregado al catálogo.") return redirect('procurement:catalog_list') else: form = ItemCatalogForm() return render(request, 'procurement/catalog_form.html', {'form': form, 'title': 'Nuevo Ítem de Catálogo'}) @login_required def catalog_edit(request, pk): item = get_object_or_404(ItemCatalog, pk=pk) if request.method == 'POST': form = ItemCatalogForm(request.POST, instance=item) if form.is_valid(): item = form.save() messages.success(request, f"Ítem {item.code} actualizado.") return redirect('procurement:catalog_list') else: form = ItemCatalogForm(instance=item) return render(request, 'procurement/catalog_form.html', {'form': form, 'title': f"Editar Ítem: {item.code}"}) # ----------------- REQUISITION VIEWS ----------------- @login_required def requisition_list(request): requisitions = PurchaseRequisition.objects.select_related('requester', 'project', 'approved_by').all().order_by('-created_at') status_filter = request.GET.get('status') if status_filter: requisitions = requisitions.filter(status=status_filter) return render(request, 'procurement/requisition_list.html', { 'requisitions': requisitions, 'selected_status': status_filter, }) @login_required def requisition_create(request): if request.method == 'POST': form = PurchaseRequisitionForm(request.POST) if form.is_valid(): req = form.save(commit=False) req.requester = request.user # Generate code REQ-2026-XXXX count = PurchaseRequisition.objects.count() + 1 req.code = f"REQ-2026-{count:04d}" req.status = RequisitionStatusEnum.SOLICITADA req.save() # Process dynamic items submitted item_descs = request.POST.getlist('item_desc[]') item_qtys = request.POST.getlist('item_qty[]') item_prices = request.POST.getlist('item_price[]') item_catalog_ids = request.POST.getlist('item_catalog_id[]') total_est = Decimal('0') for i in range(len(item_descs)): desc = item_descs[i].strip() if not desc: continue qty = Decimal(item_qtys[i] or '1') price = Decimal(item_prices[i] or '0') cat_id = item_catalog_ids[i] if i < len(item_catalog_ids) and item_catalog_ids[i] else None cat_obj = ItemCatalog.objects.filter(id=cat_id).first() if cat_id else None subtotal = qty * price total_est += subtotal RequisitionItem.objects.create( requisition=req, catalog_item=cat_obj, description=desc, quantity=qty, unit_price=price, subtotal=subtotal ) req.total_estimated_clp = total_est req.save(update_fields=['total_estimated_clp']) messages.success(request, f"Requisición {req.code} creada con éxito y enviada a revisión técnica.") return redirect('procurement:requisition_detail', pk=req.pk) else: form = PurchaseRequisitionForm() catalog_items = ItemCatalog.objects.filter(is_active=True).order_by('code') return render(request, 'procurement/requisition_form.html', { 'form': form, 'catalog_items': catalog_items }) @login_required def requisition_detail(request, pk): req = get_object_or_404(PurchaseRequisition.objects.select_related('requester', 'project', 'approved_by'), pk=pk) return render(request, 'procurement/requisition_detail.html', {'req': req}) @login_required def requisition_action(request, pk, action): req = get_object_or_404(PurchaseRequisition, pk=pk) if request.method == 'POST': if action == 'approve_tech': req.status = RequisitionStatusEnum.APROBADA_TECNICA messages.success(request, f"Requisición {req.code} aprobada técnicamente.") elif action == 'approve_finance': req.status = RequisitionStatusEnum.APROBADA_FINANZAS req.approved_by = request.user req.approved_at = timezone.now() messages.success(request, f"Requisición {req.code} aprobada por Finanzas. Lista para emitir OC.") elif action == 'reject': req.status = RequisitionStatusEnum.RECHAZADA req.rejection_reason = request.POST.get('reason', 'Sin motivo especificado') messages.warning(request, f"Requisición {req.code} rechazada.") elif action == 'convert_po': # Create PO directly count = PurchaseOrder.objects.count() + 1 po_code = f"OC-2026-{count:04d}" # Default vendor: from first catalog item or first available vendor = None first_item = req.items.first() if first_item and first_item.catalog_item and first_item.catalog_item.preferred_vendor: vendor = first_item.catalog_item.preferred_vendor else: vendor = Vendor.objects.first() po = PurchaseOrder.objects.create( code=po_code, requisition=req, vendor=vendor, project=req.project, issuer=request.user, issue_date=date.today(), payment_terms="30 Días Contra Factura", delivery_address="Casa Matriz EFINTEL / Terreno Faena", net_amount_clp=req.total_estimated_clp, tax_rate=Decimal('19.00'), tax_amount_clp=(req.total_estimated_clp * Decimal('0.19')).quantize(Decimal('1')), total_amount_clp=(req.total_estimated_clp * Decimal('1.19')).quantize(Decimal('1')), status=PurchaseOrderStatusEnum.EMITIDA ) for item in req.items.all(): PurchaseOrderItem.objects.create( purchase_order=po, catalog_item=item.catalog_item, description=item.description, quantity=item.quantity, unit_price_net=item.unit_price, subtotal_net=item.subtotal ) req.status = RequisitionStatusEnum.CONVERTIDA_OC req.save(update_fields=['status']) messages.success(request, f"Se generó exitosamente la Orden de Compra {po.code} a partir de la requisición.") return redirect('procurement:purchase_order_detail', pk=po.pk) req.save() return redirect('procurement:requisition_detail', pk=pk) # ----------------- PURCHASE ORDER VIEWS ----------------- @login_required def purchase_order_list(request): orders = PurchaseOrder.objects.select_related('vendor', 'project', 'issuer').all().order_by('-issue_date', '-created_at') status_filter = request.GET.get('status') if status_filter: orders = orders.filter(status=status_filter) totals = orders.aggregate( total_net=Sum('net_amount_clp'), total_tax=Sum('tax_amount_clp'), total_gross=Sum('total_amount_clp') ) return render(request, 'procurement/purchase_order_list.html', { 'orders': orders, 'selected_status': status_filter, 'total_net': totals['total_net'] or 0, 'total_tax': totals['total_tax'] or 0, 'total_gross': totals['total_gross'] or 0, }) @login_required def purchase_order_create(request): if request.method == 'POST': form = PurchaseOrderForm(request.POST) if form.is_valid(): po = form.save(commit=False) po.issuer = request.user count = PurchaseOrder.objects.count() + 1 po.code = f"OC-2026-{count:04d}" po.save() # Items item_descs = request.POST.getlist('item_desc[]') item_qtys = request.POST.getlist('item_qty[]') item_prices = request.POST.getlist('item_price[]') item_catalog_ids = request.POST.getlist('item_catalog_id[]') neto_total = Decimal('0') for i in range(len(item_descs)): desc = item_descs[i].strip() if not desc: continue qty = Decimal(item_qtys[i] or '1') price = Decimal(item_prices[i] or '0') cat_id = item_catalog_ids[i] if i < len(item_catalog_ids) and item_catalog_ids[i] else None cat_obj = ItemCatalog.objects.filter(id=cat_id).first() if cat_id else None subtotal = qty * price neto_total += subtotal PurchaseOrderItem.objects.create( purchase_order=po, catalog_item=cat_obj, description=desc, quantity=qty, unit_price_net=price, subtotal_net=subtotal ) po.net_amount_clp = neto_total po.tax_amount_clp = (neto_total * Decimal('0.19')).quantize(Decimal('1')) po.total_amount_clp = po.net_amount_clp + po.tax_amount_clp po.save() messages.success(request, f"Orden de Compra {po.code} generada exitosamente.") return redirect('procurement:purchase_order_detail', pk=po.pk) else: form = PurchaseOrderForm(initial={'issue_date': date.today()}) catalog_items = ItemCatalog.objects.filter(is_active=True).order_by('code') return render(request, 'procurement/purchase_order_form.html', { 'form': form, 'catalog_items': catalog_items }) @login_required def purchase_order_detail(request, pk): po = get_object_or_404(PurchaseOrder.objects.select_related('vendor', 'project', 'issuer', 'requisition'), pk=pk) return render(request, 'procurement/purchase_order_detail.html', {'po': po}) # ----------------- GOODS RECEIPT VIEWS ----------------- @login_required def goods_receipt_create(request, po_pk=None): initial = {'receipt_date': date.today()} if po_pk: po = get_object_or_404(PurchaseOrder, pk=po_pk) initial['purchase_order'] = po if request.method == 'POST': form = GoodsReceiptForm(request.POST) if form.is_valid(): gr = form.save(commit=False) gr.received_by = request.user count = GoodsReceipt.objects.count() + 1 gr.code = f"REC-2026-{count:04d}" gr.save() # Update PO status to reception po = gr.purchase_order po.status = PurchaseOrderStatusEnum.RECEPCION_TOTAL if gr.technical_conformity else PurchaseOrderStatusEnum.RECEPCION_PARCIAL po.save(update_fields=['status']) messages.success(request, f"Acta de Recepción {gr.code} registrada conforme para la OC {po.code}.") return redirect('procurement:purchase_order_detail', pk=po.pk) else: form = GoodsReceiptForm(initial=initial) return render(request, 'procurement/goods_receipt_form.html', {'form': form}) # ----------------- 3-WAY MATCHING VIEWS ----------------- @login_required def three_way_match_list(request): matches = ThreeWayMatch.objects.select_related('purchase_order', 'goods_receipt', 'matched_by', 'purchase_order__vendor').all().order_by('-created_at') # Also find POs pending matching matched_po_ids = ThreeWayMatch.objects.values_list('purchase_order_id', flat=True) pending_pos = PurchaseOrder.objects.exclude(id__in=matched_po_ids).select_related('vendor', 'project') return render(request, 'procurement/three_way_match.html', { 'matches': matches, 'pending_pos': pending_pos }) @login_required def three_way_match_create(request): po_id = request.GET.get('po_id') or request.POST.get('purchase_order') po = get_object_or_404(PurchaseOrder, pk=po_id) if po_id else None if request.method == 'POST': form = ThreeWayMatchForm(request.POST) if form.is_valid(): match = form.save(commit=False) match.matched_by = request.user match.matched_at = timezone.now() # Evaluate match with engine (0% tolerance) eval_result = ThreeWayMatchingEngine.evaluate_match( po=match.purchase_order, goods_receipt=match.goods_receipt, dte_invoice_net=match.dte_net_amount, dte_invoice_total=match.dte_total_amount, dte_invoice_rut=match.dte_invoice_rut ) match.price_difference_clp = eval_result['price_difference'] match.match_status = eval_result['status'] match.save() if eval_result['is_match']: match.purchase_order.status = PurchaseOrderStatusEnum.CONCILIADA_MATCH match.purchase_order.save(update_fields=['status']) messages.success(request, f"¡3-Way Match Exitoso! {eval_result['message']}") else: messages.warning(request, f"Alerta 3-Way Match: {eval_result['message']}") return redirect('procurement:three_way_match_list') else: initial = {'dte_invoice_date': date.today()} if po: initial['purchase_order'] = po initial['dte_net_amount'] = po.net_amount_clp initial['dte_tax_amount'] = po.tax_amount_clp initial['dte_total_amount'] = po.total_amount_clp initial['dte_invoice_rut'] = po.vendor.rut latest_receipt = po.receipts.first() if latest_receipt: initial['goods_receipt'] = latest_receipt form = ThreeWayMatchForm(initial=initial) return render(request, 'procurement/three_way_match_form.html', {'form': form, 'po': po})