iteracion 2
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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})
|
||||
Reference in New Issue
Block a user