Files
2026-09-07 18:44:32 -03:00

380 lines
16 KiB
Python

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})