380 lines
16 KiB
Python
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})
|