|
|
@@ -20,6 +20,9 @@ from .models import (
|
|
20
|
20
|
ProvidedService,
|
|
21
|
21
|
Customer,
|
|
22
|
22
|
CustomerSite,
|
|
|
23
|
+ PickupItem,
|
|
|
24
|
+ PriceList,
|
|
|
25
|
+ PriceListItem,
|
|
23
|
26
|
PickupOrder,
|
|
24
|
27
|
WeighTicket,
|
|
25
|
28
|
WeighLine,
|
|
|
@@ -27,6 +30,9 @@ from .models import (
|
|
27
|
30
|
ScrapBid,
|
|
28
|
31
|
Document,
|
|
29
|
32
|
AuditLog,
|
|
|
33
|
+ CarbonEvent,
|
|
|
34
|
+ CarbonBalance,
|
|
|
35
|
+ MaterialEmissionFactor,
|
|
30
|
36
|
)
|
|
31
|
37
|
from .forms import (
|
|
32
|
38
|
MaterialForm,
|
|
|
@@ -37,8 +43,14 @@ from .forms import (
|
|
37
|
43
|
DocumentForm,
|
|
38
|
44
|
ProvidedServiceForm,
|
|
39
|
45
|
WeighTicketForm,
|
|
|
46
|
+ WeighLineForm,
|
|
|
47
|
+ WeighTicketWeightsForm,
|
|
|
48
|
+ MaterialEmissionFactorForm,
|
|
|
49
|
+ PickupItemForm,
|
|
40
|
50
|
)
|
|
41
|
|
-from .services.billing import generate_invoice_for_pickup
|
|
|
51
|
+from .services.billing import generate_invoice_for_pickup, regenerate_invoice_for_pickup
|
|
|
52
|
+from billing.models import Invoice
|
|
|
53
|
+from .services import carbon as carbon_svc
|
|
42
|
54
|
from django.contrib.contenttypes.models import ContentType
|
|
43
|
55
|
from django.contrib.auth import get_user_model
|
|
44
|
56
|
from django.http import HttpResponse, JsonResponse
|
|
|
@@ -226,11 +238,26 @@ def org_user_edit(request, pk: int):
|
|
226
|
238
|
"is_active": user.is_active,
|
|
227
|
239
|
})
|
|
228
|
240
|
form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
|
|
229
|
|
- print(form_photo)
|
|
|
241
|
+ # Extra contact/address fields on the profile
|
|
|
242
|
+ from django.forms import modelform_factory
|
|
|
243
|
+ ExtraForm = modelform_factory(UserProfile, fields=(
|
|
|
244
|
+ "phone",
|
|
|
245
|
+ "job_title",
|
|
|
246
|
+ "department",
|
|
|
247
|
+ "preferred_language",
|
|
|
248
|
+ "address_line1",
|
|
|
249
|
+ "address_line2",
|
|
|
250
|
+ "city",
|
|
|
251
|
+ "state",
|
|
|
252
|
+ "postal_code",
|
|
|
253
|
+ "country",
|
|
|
254
|
+ ))
|
|
|
255
|
+ form_extras = ExtraForm(request.POST or None, instance=profile) if profile else None
|
|
230
|
256
|
if request.method == "POST":
|
|
231
|
257
|
ok_main = form.is_valid()
|
|
232
|
258
|
ok_photo = True if form_photo is None else form_photo.is_valid()
|
|
233
|
|
- if ok_main and ok_photo:
|
|
|
259
|
+ ok_extras = True if form_extras is None else form_extras.is_valid()
|
|
|
260
|
+ if ok_main and ok_photo and ok_extras:
|
|
234
|
261
|
user.email = form.cleaned_data.get("email") or ""
|
|
235
|
262
|
user.first_name = form.cleaned_data.get("first_name") or ""
|
|
236
|
263
|
user.last_name = form.cleaned_data.get("last_name") or ""
|
|
|
@@ -252,14 +279,16 @@ def org_user_edit(request, pk: int):
|
|
252
|
279
|
if form_photo is not None:
|
|
253
|
280
|
form_photo.instance = profile
|
|
254
|
281
|
form_photo.save()
|
|
|
282
|
+ if form_extras is not None:
|
|
|
283
|
+ form_extras.instance = profile
|
|
|
284
|
+ form_extras.save()
|
|
255
|
285
|
messages.success(request, f"User '{user.username}' updated.")
|
|
256
|
286
|
next_url = request.POST.get("next") or request.GET.get("next")
|
|
257
|
287
|
if next_url:
|
|
258
|
288
|
return redirect(next_url)
|
|
259
|
289
|
return redirect("recycle_core:org_users_list")
|
|
260
|
290
|
messages.error(request, "Please correct the errors below.")
|
|
261
|
|
- print(f"fp = {form_photo}")
|
|
262
|
|
- return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
|
|
|
291
|
+ return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "form_extras": form_extras, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
|
|
263
|
292
|
|
|
264
|
293
|
|
|
265
|
294
|
@owner_required
|
|
|
@@ -534,10 +563,76 @@ def pickup_detail(request, pk: int):
|
|
534
|
563
|
PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
|
|
535
|
564
|
)
|
|
536
|
565
|
items = pickup.items.select_related("material").all()
|
|
|
566
|
+ add_item_form = None
|
|
|
567
|
+ if request.method == "POST":
|
|
|
568
|
+ action = request.POST.get("action")
|
|
|
569
|
+ if action == "add_item":
|
|
|
570
|
+ add_item_form = PickupItemForm(request.POST, organization=pickup.organization)
|
|
|
571
|
+ if add_item_form.is_valid():
|
|
|
572
|
+ PickupItem.objects.create(
|
|
|
573
|
+ pickup=pickup,
|
|
|
574
|
+ material=add_item_form.cleaned_data["material"],
|
|
|
575
|
+ estimated_qty=add_item_form.cleaned_data.get("estimated_qty"),
|
|
|
576
|
+ unit=add_item_form.cleaned_data["unit"],
|
|
|
577
|
+ )
|
|
|
578
|
+ messages.success(request, "Pickup item added.")
|
|
|
579
|
+ return redirect("recycle_core:pickup_detail", pk=pickup.id)
|
|
|
580
|
+ else:
|
|
|
581
|
+ messages.error(request, "Please correct the item form errors.")
|
|
|
582
|
+ if add_item_form is None:
|
|
|
583
|
+ add_item_form = PickupItemForm(organization=pickup.organization)
|
|
|
584
|
+ # Invoices associated with this pickup (with payment totals/balance)
|
|
|
585
|
+ inv_qs = (
|
|
|
586
|
+ Invoice.objects.select_related("customer")
|
|
|
587
|
+ .filter(pickup=pickup)
|
|
|
588
|
+ .annotate(
|
|
|
589
|
+ payments_total=Coalesce(
|
|
|
590
|
+ Sum("payments__amount"),
|
|
|
591
|
+ Value(Decimal("0.00")),
|
|
|
592
|
+ output_field=DecimalField(max_digits=14, decimal_places=2),
|
|
|
593
|
+ )
|
|
|
594
|
+ )
|
|
|
595
|
+ .annotate(
|
|
|
596
|
+ balance=ExpressionWrapper(
|
|
|
597
|
+ F("total_amount") - F("payments_total"),
|
|
|
598
|
+ output_field=DecimalField(max_digits=14, decimal_places=2),
|
|
|
599
|
+ )
|
|
|
600
|
+ )
|
|
|
601
|
+ .order_by("-issued_at", "-id")
|
|
|
602
|
+ )
|
|
|
603
|
+
|
|
|
604
|
+ # Compute per-ticket pricing summary (unit price avg and total)
|
|
|
605
|
+ # Attach pricing summaries on each ticket
|
|
|
606
|
+ price_list = pickup.customer.price_list or PriceList.objects.filter(organization=pickup.organization).order_by("id").first()
|
|
|
607
|
+ currency_code = getattr(price_list, "currency_code", None) or pickup.organization.currency_code
|
|
|
608
|
+ for t in pickup.weigh_tickets.all():
|
|
|
609
|
+ total = Decimal("0.00")
|
|
|
610
|
+ for ln in t.lines.all():
|
|
|
611
|
+ pli = (
|
|
|
612
|
+ PriceListItem.objects.filter(
|
|
|
613
|
+ price_list=price_list, material_id=ln.material_id, unit=ln.unit, direction=PriceListItem.DIRECTION_SELL
|
|
|
614
|
+ ).first()
|
|
|
615
|
+ if price_list
|
|
|
616
|
+ else None
|
|
|
617
|
+ )
|
|
|
618
|
+ if pli and pli.unit_price and Decimal(pli.unit_price) > 0:
|
|
|
619
|
+ total += (Decimal(ln.quantity) * Decimal(pli.unit_price)).quantize(Decimal("0.01"))
|
|
|
620
|
+ avg_unit = None
|
|
|
621
|
+ try:
|
|
|
622
|
+ if total > 0 and t.net_weight:
|
|
|
623
|
+ avg_unit = (total / Decimal(t.net_weight)).quantize(Decimal("0.01"))
|
|
|
624
|
+ except Exception:
|
|
|
625
|
+ avg_unit = None
|
|
|
626
|
+ t.total_value = total
|
|
|
627
|
+ t.avg_unit = avg_unit
|
|
|
628
|
+
|
|
537
|
629
|
context = {
|
|
538
|
630
|
"nav": _nav_items(),
|
|
539
|
631
|
"pickup": pickup,
|
|
540
|
632
|
"items": items,
|
|
|
633
|
+ "add_item_form": add_item_form,
|
|
|
634
|
+ "invoices": list(inv_qs),
|
|
|
635
|
+ "ticket_currency": currency_code,
|
|
541
|
636
|
}
|
|
542
|
637
|
return render(request, "recycle_core/pickup_detail.html", context)
|
|
543
|
638
|
|
|
|
@@ -546,9 +641,6 @@ def pickup_detail(request, pk: int):
|
|
546
|
641
|
@permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
|
|
547
|
642
|
def pickup_create_weigh_ticket(request, pk: int):
|
|
548
|
643
|
pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
|
|
549
|
|
- if hasattr(pickup, "weigh_ticket"):
|
|
550
|
|
- messages.info(request, "Weigh ticket already exists for this pickup.")
|
|
551
|
|
- return redirect("recycle_core:weigh_ticket_detail", pk=pickup.weigh_ticket.id)
|
|
552
|
644
|
|
|
553
|
645
|
if request.method == "POST":
|
|
554
|
646
|
form = WeighTicketForm(request.POST)
|
|
|
@@ -584,14 +676,27 @@ def pickup_create_weigh_ticket(request, pk: int):
|
|
584
|
676
|
def pickup_generate_invoice(request, pk: int):
|
|
585
|
677
|
pickup = get_object_or_404(PickupOrder, pk=pk)
|
|
586
|
678
|
try:
|
|
587
|
|
- if not hasattr(pickup, "weigh_ticket"):
|
|
588
|
|
- messages.error(request, "Pickup has no weigh ticket.")
|
|
589
|
|
- return redirect("recycle_core:pickups_list")
|
|
|
679
|
+ if not pickup.weigh_tickets.exists():
|
|
|
680
|
+ messages.error(request, "Pickup has no weigh tickets.")
|
|
|
681
|
+ return redirect("recycle_core:pickup_detail", pk=pickup.id)
|
|
590
|
682
|
invoice = generate_invoice_for_pickup(pickup)
|
|
591
|
683
|
messages.success(request, f"Invoice #{invoice.id} generated.")
|
|
592
|
684
|
except Exception as e:
|
|
593
|
685
|
messages.error(request, f"Failed to generate invoice: {e}")
|
|
594
|
|
- return redirect("recycle_core:pickups_list")
|
|
|
686
|
+ return redirect("recycle_core:pickup_detail", pk=pickup.id)
|
|
|
687
|
+
|
|
|
688
|
+
|
|
|
689
|
+@staff_member_required
|
|
|
690
|
+@permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
|
|
|
691
|
+@require_POST
|
|
|
692
|
+def pickup_regenerate_invoice(request, pk: int):
|
|
|
693
|
+ pickup = get_object_or_404(PickupOrder, pk=pk)
|
|
|
694
|
+ try:
|
|
|
695
|
+ inv = regenerate_invoice_for_pickup(pickup)
|
|
|
696
|
+ messages.success(request, f"Invoice #{inv.id} regenerated from current tickets.")
|
|
|
697
|
+ except Exception as e:
|
|
|
698
|
+ messages.error(request, f"Failed to regenerate invoice: {e}")
|
|
|
699
|
+ return redirect("recycle_core:pickup_detail", pk=pickup.id)
|
|
595
|
700
|
|
|
596
|
701
|
|
|
597
|
702
|
# Scrap Listings UI ----------------------------------------------------------
|
|
|
@@ -738,17 +843,250 @@ def weigh_tickets_list(request):
|
|
738
|
843
|
@staff_member_required
|
|
739
|
844
|
@breadcrumbs(label="Ticket Detail", parent="re_tickets")
|
|
740
|
845
|
def weigh_ticket_detail(request, pk: int):
|
|
741
|
|
- ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer"), pk=pk)
|
|
|
846
|
+ ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization"), pk=pk)
|
|
742
|
847
|
lines = WeighLine.objects.select_related("material").filter(ticket=ticket)
|
|
|
848
|
+ # Add WeighLine inline form
|
|
|
849
|
+ add_line_form = None
|
|
|
850
|
+ weights_form = None
|
|
|
851
|
+ if request.method == "POST":
|
|
|
852
|
+ action = request.POST.get("action", "add")
|
|
|
853
|
+ org = ticket.pickup.organization
|
|
|
854
|
+ if action == "add":
|
|
|
855
|
+ add_line_form = WeighLineForm(request.POST, organization=org)
|
|
|
856
|
+ if add_line_form.is_valid():
|
|
|
857
|
+ WeighLine.objects.create(
|
|
|
858
|
+ ticket=ticket,
|
|
|
859
|
+ material=add_line_form.cleaned_data["material"],
|
|
|
860
|
+ quantity=add_line_form.cleaned_data["quantity"],
|
|
|
861
|
+ unit=add_line_form.cleaned_data["unit"],
|
|
|
862
|
+ )
|
|
|
863
|
+ messages.success(request, "Weigh line added.")
|
|
|
864
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
865
|
+ else:
|
|
|
866
|
+ messages.error(request, "Please correct the line form errors.")
|
|
|
867
|
+ elif action == "edit":
|
|
|
868
|
+ line_id = request.POST.get("line_id")
|
|
|
869
|
+ line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
|
|
|
870
|
+ form = WeighLineForm(request.POST, organization=org)
|
|
|
871
|
+ if form.is_valid():
|
|
|
872
|
+ line.material = form.cleaned_data["material"]
|
|
|
873
|
+ line.quantity = form.cleaned_data["quantity"]
|
|
|
874
|
+ line.unit = form.cleaned_data["unit"]
|
|
|
875
|
+ line.save()
|
|
|
876
|
+ messages.success(request, f"Line #{line.id} updated.")
|
|
|
877
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
878
|
+ else:
|
|
|
879
|
+ messages.error(request, "Please correct the line form errors.")
|
|
|
880
|
+ elif action == "delete":
|
|
|
881
|
+ line_id = request.POST.get("line_id")
|
|
|
882
|
+ line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
|
|
|
883
|
+ try:
|
|
|
884
|
+ # Also remove any linked carbon event(s)
|
|
|
885
|
+ CarbonEvent.objects.filter(weigh_line=line).delete()
|
|
|
886
|
+ except Exception:
|
|
|
887
|
+ pass
|
|
|
888
|
+ line.delete()
|
|
|
889
|
+ messages.success(request, "Weigh line deleted.")
|
|
|
890
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
891
|
+ elif action == "adjust_net_to_lines":
|
|
|
892
|
+ if not org.allow_adjust_net_to_lines:
|
|
|
893
|
+ messages.error(request, "Policy does not allow adjusting net to lines.")
|
|
|
894
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
895
|
+ # Recalculate sum in ticket unit (computed below again for display)
|
|
|
896
|
+ LB_TO_KG = Decimal("0.45359237")
|
|
|
897
|
+ def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
|
|
|
898
|
+ if from_unit == to_unit:
|
|
|
899
|
+ return qty
|
|
|
900
|
+ if from_unit == 'lb' and to_unit == 'kg':
|
|
|
901
|
+ return (qty * LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
902
|
+ if from_unit == 'kg' and to_unit == 'lb':
|
|
|
903
|
+ return (qty / LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
904
|
+ return Decimal('0')
|
|
|
905
|
+ total_lines = Decimal('0.000')
|
|
|
906
|
+ for ln in lines:
|
|
|
907
|
+ if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
|
|
|
908
|
+ total_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
|
|
|
909
|
+ ticket.net_weight = total_lines
|
|
|
910
|
+ ticket.save(update_fields=["net_weight", "updated_at"])
|
|
|
911
|
+ messages.success(request, "Net weight adjusted to sum of lines.")
|
|
|
912
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
913
|
+ elif action == "add_residual_line":
|
|
|
914
|
+ if not org.allow_add_residual_line:
|
|
|
915
|
+ messages.error(request, "Policy does not allow adding residual line.")
|
|
|
916
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
917
|
+ # Compute residual quantity = net - sum(lines in ticket unit)
|
|
|
918
|
+ LB_TO_KG = Decimal("0.45359237")
|
|
|
919
|
+ def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
|
|
|
920
|
+ if from_unit == to_unit:
|
|
|
921
|
+ return qty
|
|
|
922
|
+ if from_unit == 'lb' and to_unit == 'kg':
|
|
|
923
|
+ return (qty * LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
924
|
+ if from_unit == 'kg' and to_unit == 'lb':
|
|
|
925
|
+ return (qty / LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
926
|
+ return Decimal('0')
|
|
|
927
|
+ sum_lines = Decimal('0.000')
|
|
|
928
|
+ for ln in lines:
|
|
|
929
|
+ if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
|
|
|
930
|
+ sum_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
|
|
|
931
|
+ resid = (Decimal(ticket.net_weight) - sum_lines).quantize(Decimal('0.000'))
|
|
|
932
|
+ if resid == 0:
|
|
|
933
|
+ messages.info(request, "No residual difference to add.")
|
|
|
934
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
935
|
+ # Ensure residual material exists for the org
|
|
|
936
|
+ residual_name = "Residual"
|
|
|
937
|
+ residual = Material.objects.filter(organization=org, name=residual_name).first()
|
|
|
938
|
+ if not residual:
|
|
|
939
|
+ residual = Material.objects.create(
|
|
|
940
|
+ organization=org,
|
|
|
941
|
+ category="Mixed",
|
|
|
942
|
+ name=residual_name,
|
|
|
943
|
+ default_unit=ticket.unit,
|
|
|
944
|
+ )
|
|
|
945
|
+ WeighLine.objects.create(ticket=ticket, material=residual, quantity=resid, unit=ticket.unit)
|
|
|
946
|
+ messages.success(request, f"Residual line of {resid} {ticket.unit} added.")
|
|
|
947
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
948
|
+ elif action == "finalize_ticket":
|
|
|
949
|
+ # Validate tolerance and finalize
|
|
|
950
|
+ LB_TO_KG = Decimal("0.45359237")
|
|
|
951
|
+ def _to_kg(qty: Decimal, unit: str) -> Decimal:
|
|
|
952
|
+ if unit == 'kg':
|
|
|
953
|
+ return qty
|
|
|
954
|
+ if unit == 'lb':
|
|
|
955
|
+ return (qty * LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
956
|
+ return Decimal('0')
|
|
|
957
|
+ # compute delta in kg
|
|
|
958
|
+ sum_lines_ticket = Decimal('0.000')
|
|
|
959
|
+ for ln in lines:
|
|
|
960
|
+ if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
|
|
|
961
|
+ # convert line to ticket unit
|
|
|
962
|
+ if ln.unit == ticket.unit:
|
|
|
963
|
+ sum_lines_ticket += Decimal(ln.quantity)
|
|
|
964
|
+ else:
|
|
|
965
|
+ sum_lines_ticket += (Decimal(ln.quantity) * (LB_TO_KG if ln.unit=='lb' and ticket.unit=='kg' else Decimal('1')/LB_TO_KG)).quantize(Decimal('0.000'))
|
|
|
966
|
+ delta_ticket = (Decimal(ticket.net_weight) - sum_lines_ticket)
|
|
|
967
|
+ delta_kg = _to_kg(delta_ticket, ticket.unit)
|
|
|
968
|
+ net_kg = _to_kg(Decimal(ticket.net_weight), ticket.unit)
|
|
|
969
|
+ abs_tol_kg = org.ticket_tolerance_abs_kg
|
|
|
970
|
+ pct_tol_kg = (net_kg * (org.ticket_tolerance_pct / Decimal('100'))).quantize(Decimal('0.000')) if net_kg else Decimal('0.000')
|
|
|
971
|
+ tol_kg = abs_tol_kg if abs_tol_kg >= pct_tol_kg else pct_tol_kg
|
|
|
972
|
+ if delta_kg.copy_abs() > tol_kg:
|
|
|
973
|
+ messages.error(request, f"Cannot finalize: difference {delta_kg} kg exceeds tolerance {tol_kg} kg.")
|
|
|
974
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
975
|
+ ticket.is_finalized = True
|
|
|
976
|
+ ticket.finalized_by = request.user
|
|
|
977
|
+ from django.utils import timezone as djtz
|
|
|
978
|
+ ticket.finalized_at = djtz.now()
|
|
|
979
|
+ ticket.save(update_fields=["is_finalized", "finalized_by", "finalized_at", "updated_at"])
|
|
|
980
|
+ messages.success(request, "Ticket finalized.")
|
|
|
981
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
982
|
+ elif action == "edit_weights":
|
|
|
983
|
+ # Permission and state checks
|
|
|
984
|
+ if not request.user.has_perm('recycle_core.edit_weigh_ticket'):
|
|
|
985
|
+ messages.error(request, "You are not allowed to edit weights.")
|
|
|
986
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
987
|
+ if ticket.is_finalized:
|
|
|
988
|
+ messages.error(request, "Ticket is finalized and cannot be edited.")
|
|
|
989
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
990
|
+ wform = WeighTicketWeightsForm(request.POST)
|
|
|
991
|
+ if wform.is_valid():
|
|
|
992
|
+ gross = wform.cleaned_data['gross_weight']
|
|
|
993
|
+ tare = wform.cleaned_data['tare_weight']
|
|
|
994
|
+ unit = wform.cleaned_data['unit']
|
|
|
995
|
+ # Disallow unit change if lines exist
|
|
|
996
|
+ if unit != ticket.unit and lines.exists():
|
|
|
997
|
+ messages.error(request, "Cannot change unit when lines exist. Remove lines first or keep unit.")
|
|
|
998
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
999
|
+ old = {
|
|
|
1000
|
+ 'gross': str(ticket.gross_weight),
|
|
|
1001
|
+ 'tare': str(ticket.tare_weight),
|
|
|
1002
|
+ 'net': str(ticket.net_weight),
|
|
|
1003
|
+ 'unit': ticket.unit,
|
|
|
1004
|
+ }
|
|
|
1005
|
+ ticket.gross_weight = gross
|
|
|
1006
|
+ ticket.tare_weight = tare
|
|
|
1007
|
+ ticket.net_weight = (Decimal(gross) - Decimal(tare)).quantize(Decimal('0.000'))
|
|
|
1008
|
+ ticket.unit = unit
|
|
|
1009
|
+ ticket.save(update_fields=["gross_weight", "tare_weight", "net_weight", "unit", "updated_at"])
|
|
|
1010
|
+ try:
|
|
|
1011
|
+ from .services.audit import log_action
|
|
|
1012
|
+ log_action(
|
|
|
1013
|
+ organization=org,
|
|
|
1014
|
+ user=request.user,
|
|
|
1015
|
+ action="weigh_ticket.update_weights",
|
|
|
1016
|
+ obj=ticket,
|
|
|
1017
|
+ metadata={"before": old, "after": {"gross": str(gross), "tare": str(tare), "net": str(ticket.net_weight), "unit": unit}},
|
|
|
1018
|
+ )
|
|
|
1019
|
+ except Exception:
|
|
|
1020
|
+ pass
|
|
|
1021
|
+ messages.success(request, "Weights updated.")
|
|
|
1022
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
|
1023
|
+ else:
|
|
|
1024
|
+ messages.error(request, "Please correct the weights form errors.")
|
|
|
1025
|
+ weights_form = wform # keep field errors for rendering
|
|
|
1026
|
+ else:
|
|
|
1027
|
+ add_line_form = WeighLineForm(organization=ticket.pickup.organization, initial={"unit": ticket.unit})
|
|
743
|
1028
|
ct = ContentType.objects.get_for_model(WeighTicket)
|
|
744
|
1029
|
docs = Document.objects.filter(content_type=ct, object_id=ticket.id)
|
|
|
1030
|
+ # Forms for editing existing lines (attach to each line)
|
|
|
1031
|
+ for ln in lines:
|
|
|
1032
|
+ ln.form = WeighLineForm(
|
|
|
1033
|
+ organization=ticket.pickup.organization,
|
|
|
1034
|
+ initial={"material": ln.material_id, "quantity": ln.quantity, "unit": ln.unit},
|
|
|
1035
|
+ )
|
|
|
1036
|
+
|
|
|
1037
|
+ # Compute lines total converted to ticket unit and delta to net weight
|
|
|
1038
|
+ LB_TO_KG = Decimal("0.45359237")
|
|
|
1039
|
+ def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
|
|
|
1040
|
+ if from_unit == to_unit:
|
|
|
1041
|
+ return qty
|
|
|
1042
|
+ if from_unit == 'lb' and to_unit == 'kg':
|
|
|
1043
|
+ return (qty * LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
1044
|
+ if from_unit == 'kg' and to_unit == 'lb':
|
|
|
1045
|
+ return (qty / LB_TO_KG).quantize(Decimal('0.000'))
|
|
|
1046
|
+ # pcs or unknown: cannot convert; return 0 so we don't skew sums
|
|
|
1047
|
+ return Decimal('0')
|
|
|
1048
|
+
|
|
|
1049
|
+ sum_in_ticket_unit = Decimal('0.000')
|
|
|
1050
|
+ has_unconvertible = False
|
|
|
1051
|
+ for ln in lines:
|
|
|
1052
|
+ if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
|
|
|
1053
|
+ sum_in_ticket_unit += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
|
|
|
1054
|
+ else:
|
|
|
1055
|
+ # pcs or mixed with pcs — flag as unconvertible
|
|
|
1056
|
+ if ln.unit != ticket.unit:
|
|
|
1057
|
+ has_unconvertible = True
|
|
|
1058
|
+ else:
|
|
|
1059
|
+ # same unit (e.g., pcs) — include as-is
|
|
|
1060
|
+ sum_in_ticket_unit += Decimal(ln.quantity)
|
|
|
1061
|
+ try:
|
|
|
1062
|
+ net = Decimal(ticket.net_weight)
|
|
|
1063
|
+ except Exception:
|
|
|
1064
|
+ net = Decimal('0.000')
|
|
|
1065
|
+ delta = (net - sum_in_ticket_unit).quantize(Decimal('0.000'))
|
|
|
1066
|
+
|
|
|
1067
|
+ # Prepare weights edit form (pre-filled) when not coming from a failed POST
|
|
|
1068
|
+ if weights_form is None:
|
|
|
1069
|
+ weights_form = WeighTicketWeightsForm(initial={
|
|
|
1070
|
+ 'gross_weight': ticket.gross_weight,
|
|
|
1071
|
+ 'tare_weight': ticket.tare_weight,
|
|
|
1072
|
+ 'unit': ticket.unit,
|
|
|
1073
|
+ })
|
|
|
1074
|
+
|
|
745
|
1075
|
context = {
|
|
746
|
1076
|
"nav": _nav_items(),
|
|
747
|
1077
|
"ticket": ticket,
|
|
748
|
1078
|
"lines": lines,
|
|
749
|
1079
|
"generate_url": reverse("recycle_core:weigh_ticket_generate_invoice", args=[ticket.id]),
|
|
|
1080
|
+ "add_line_form": add_line_form,
|
|
|
1081
|
+ "weights_form": weights_form,
|
|
750
|
1082
|
"documents": docs,
|
|
751
|
1083
|
"ct_id": ct.id,
|
|
|
1084
|
+ "lines_sum": sum_in_ticket_unit,
|
|
|
1085
|
+ "lines_delta": delta,
|
|
|
1086
|
+ "has_unconvertible": has_unconvertible,
|
|
|
1087
|
+ "allow_adjust_net": ticket.pickup.organization.allow_adjust_net_to_lines,
|
|
|
1088
|
+ "allow_residual": ticket.pickup.organization.allow_add_residual_line,
|
|
|
1089
|
+ "is_finalized": ticket.is_finalized,
|
|
752
|
1090
|
}
|
|
753
|
1091
|
return render(request, "recycle_core/weigh_ticket_detail.html", context)
|
|
754
|
1092
|
|
|
|
@@ -763,7 +1101,19 @@ def weigh_ticket_generate_invoice(request, pk: int):
|
|
763
|
1101
|
messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
|
|
764
|
1102
|
except Exception as e:
|
|
765
|
1103
|
messages.error(request, f"Failed to generate invoice: {e}")
|
|
766
|
|
- return redirect("recycle_core:weigh_tickets_list")
|
|
|
1104
|
+ return redirect("recycle_core:pickup_detail", pk=ticket.pickup_id)
|
|
|
1105
|
+
|
|
|
1106
|
+
|
|
|
1107
|
+@staff_member_required
|
|
|
1108
|
+@require_POST
|
|
|
1109
|
+def weigh_ticket_recalc_carbon(request, pk: int):
|
|
|
1110
|
+ ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__organization"), pk=pk)
|
|
|
1111
|
+ try:
|
|
|
1112
|
+ events = carbon_svc.calculate_events_for_ticket(ticket)
|
|
|
1113
|
+ messages.success(request, f"Recalculated carbon events: {len(events)} event(s).")
|
|
|
1114
|
+ except Exception as e:
|
|
|
1115
|
+ messages.error(request, f"Failed to recalculate carbon events: {e}")
|
|
|
1116
|
+ return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
|
|
767
|
1117
|
|
|
768
|
1118
|
|
|
769
|
1119
|
# Billing-related views moved to billing/views.py
|
|
|
@@ -927,6 +1277,173 @@ def audit_logs_export_csv(request):
|
|
927
|
1277
|
log.metadata,
|
|
928
|
1278
|
])
|
|
929
|
1279
|
return response
|
|
|
1280
|
+
|
|
|
1281
|
+
|
|
|
1282
|
+# Carbon approvals queue -----------------------------------------------------
|
|
|
1283
|
+
|
|
|
1284
|
+
|
|
|
1285
|
+@staff_member_required
|
|
|
1286
|
+@public_route(label="Carbon Approvals", order=65, icon="check-circle")
|
|
|
1287
|
+@breadcrumbs(label="Carbon Events", name="re_carbon_events")
|
|
|
1288
|
+def carbon_events_list(request):
|
|
|
1289
|
+ class CarbonFilter(filters.FilterSet):
|
|
|
1290
|
+ organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
|
|
|
1291
|
+ status = filters.ChoiceFilter(field_name="status", choices=CarbonEvent.STATUS_CHOICES)
|
|
|
1292
|
+ event_date = filters.DateFromToRangeFilter(field_name="event_date", label="Event between")
|
|
|
1293
|
+
|
|
|
1294
|
+ class Meta:
|
|
|
1295
|
+ model = CarbonEvent
|
|
|
1296
|
+ fields = ["organization", "status", "event_date"]
|
|
|
1297
|
+
|
|
|
1298
|
+ base_qs = CarbonEvent.objects.select_related("organization", "weigh_ticket", "material").order_by("-event_date")
|
|
|
1299
|
+ c_filter = CarbonFilter(request.GET, queryset=base_qs)
|
|
|
1300
|
+ qs = c_filter.qs
|
|
|
1301
|
+ org = getattr(request, "org", None)
|
|
|
1302
|
+ if org is not None:
|
|
|
1303
|
+ qs = qs.filter(organization=org)
|
|
|
1304
|
+
|
|
|
1305
|
+ if request.method == "POST":
|
|
|
1306
|
+ action = request.POST.get("action")
|
|
|
1307
|
+ ids = request.POST.getlist("ids")
|
|
|
1308
|
+ sel = list(qs.filter(id__in=ids))
|
|
|
1309
|
+ if action == "approve":
|
|
|
1310
|
+ n_total = 0
|
|
|
1311
|
+ for ev in sel:
|
|
|
1312
|
+ n_total += carbon_svc.approve_events(ev.organization, [ev], approver=request.user)
|
|
|
1313
|
+ messages.success(request, f"Approved {n_total} event(s).")
|
|
|
1314
|
+ return redirect("recycle_core:carbon_events_list")
|
|
|
1315
|
+ elif action == "reject":
|
|
|
1316
|
+ from django.utils import timezone
|
|
|
1317
|
+ now = timezone.now()
|
|
|
1318
|
+ for ev in sel:
|
|
|
1319
|
+ ev.status = CarbonEvent.STATUS_REJECTED
|
|
|
1320
|
+ ev.approved_by = request.user
|
|
|
1321
|
+ ev.approved_at = now
|
|
|
1322
|
+ ev.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
|
|
|
1323
|
+ messages.success(request, f"Rejected {len(sel)} event(s).")
|
|
|
1324
|
+ return redirect("recycle_core:carbon_events_list")
|
|
|
1325
|
+ else:
|
|
|
1326
|
+ messages.error(request, "Invalid action.")
|
|
|
1327
|
+
|
|
|
1328
|
+ paginator = Paginator(qs, 10)
|
|
|
1329
|
+ page = request.GET.get("page")
|
|
|
1330
|
+ try:
|
|
|
1331
|
+ page_obj = paginator.page(page)
|
|
|
1332
|
+ except PageNotAnInteger:
|
|
|
1333
|
+ page_obj = paginator.page(1)
|
|
|
1334
|
+ except EmptyPage:
|
|
|
1335
|
+ page_obj = paginator.page(paginator.num_pages)
|
|
|
1336
|
+
|
|
|
1337
|
+ context = {
|
|
|
1338
|
+ "nav": _nav_items(),
|
|
|
1339
|
+ "events": page_obj.object_list,
|
|
|
1340
|
+ "page_obj": page_obj,
|
|
|
1341
|
+ "filter": c_filter,
|
|
|
1342
|
+ }
|
|
|
1343
|
+ return render(request, "recycle_core/carbon_events_list.html", context)
|
|
|
1344
|
+
|
|
|
1345
|
+
|
|
|
1346
|
+# Emission Factors -----------------------------------------------------------
|
|
|
1347
|
+
|
|
|
1348
|
+
|
|
|
1349
|
+@staff_member_required
|
|
|
1350
|
+@public_route(label="Emission Factors", order=62, icon="bolt")
|
|
|
1351
|
+@breadcrumbs(label="Emission Factors", name="re_factors")
|
|
|
1352
|
+def emission_factors_list(request):
|
|
|
1353
|
+ class FactorFilter(filters.FilterSet):
|
|
|
1354
|
+ organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
|
|
|
1355
|
+ material = filters.ModelChoiceFilter(queryset=Material.objects.all())
|
|
|
1356
|
+ unit = filters.ChoiceFilter(choices=Material.UNIT_CHOICES)
|
|
|
1357
|
+ effective_from = filters.DateFromToRangeFilter(field_name="effective_from", label="Effective from")
|
|
|
1358
|
+
|
|
|
1359
|
+ class Meta:
|
|
|
1360
|
+ model = MaterialEmissionFactor
|
|
|
1361
|
+ fields = ["organization", "material", "unit", "effective_from"]
|
|
|
1362
|
+
|
|
|
1363
|
+ form = MaterialEmissionFactorForm(request.POST or None)
|
|
|
1364
|
+ # Scope org choices
|
|
|
1365
|
+ if getattr(request, "org", None) is not None:
|
|
|
1366
|
+ try:
|
|
|
1367
|
+ form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
|
|
|
1368
|
+ form.fields["material"].queryset = Material.objects.filter(organization=request.org)
|
|
|
1369
|
+ except Exception:
|
|
|
1370
|
+ pass
|
|
|
1371
|
+ if request.method == "POST":
|
|
|
1372
|
+ if form.is_valid():
|
|
|
1373
|
+ obj = form.save(commit=False)
|
|
|
1374
|
+ if getattr(request, "org", None) is not None:
|
|
|
1375
|
+ obj.organization = request.org
|
|
|
1376
|
+ obj.save()
|
|
|
1377
|
+ messages.success(request, "Emission factor created.")
|
|
|
1378
|
+ return redirect("recycle_core:emission_factors_list")
|
|
|
1379
|
+ else:
|
|
|
1380
|
+ messages.error(request, "Please correct the errors below.")
|
|
|
1381
|
+
|
|
|
1382
|
+ base_qs = MaterialEmissionFactor.objects.select_related("organization", "material").order_by("organization_id", "material_id", "-effective_from", "-id")
|
|
|
1383
|
+ f_filter = FactorFilter(request.GET, queryset=base_qs)
|
|
|
1384
|
+ qs = f_filter.qs
|
|
|
1385
|
+ if getattr(request, "org", None) is not None:
|
|
|
1386
|
+ qs = qs.filter(organization=request.org)
|
|
|
1387
|
+
|
|
|
1388
|
+ paginator = Paginator(qs, 15)
|
|
|
1389
|
+ page = request.GET.get("page")
|
|
|
1390
|
+ try:
|
|
|
1391
|
+ page_obj = paginator.page(page)
|
|
|
1392
|
+ except PageNotAnInteger:
|
|
|
1393
|
+ page_obj = paginator.page(1)
|
|
|
1394
|
+ except EmptyPage:
|
|
|
1395
|
+ page_obj = paginator.page(paginator.num_pages)
|
|
|
1396
|
+
|
|
|
1397
|
+ context = {
|
|
|
1398
|
+ "nav": _nav_items(),
|
|
|
1399
|
+ "factors": page_obj.object_list,
|
|
|
1400
|
+ "page_obj": page_obj,
|
|
|
1401
|
+ "filter": f_filter,
|
|
|
1402
|
+ "form": form,
|
|
|
1403
|
+ }
|
|
|
1404
|
+ return render(request, "recycle_core/emission_factors_list.html", context)
|
|
|
1405
|
+
|
|
|
1406
|
+
|
|
|
1407
|
+@staff_member_required
|
|
|
1408
|
+@breadcrumbs(label="Edit Factor", parent="re_factors")
|
|
|
1409
|
+def emission_factor_edit(request, pk: int):
|
|
|
1410
|
+ item = get_object_or_404(MaterialEmissionFactor.objects.select_related("organization", "material"), pk=pk)
|
|
|
1411
|
+ if request.method == "POST":
|
|
|
1412
|
+ form = MaterialEmissionFactorForm(request.POST, instance=item)
|
|
|
1413
|
+ # Scope choices
|
|
|
1414
|
+ if getattr(request, "org", None) is not None:
|
|
|
1415
|
+ try:
|
|
|
1416
|
+ form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
|
|
|
1417
|
+ form.fields["material"].queryset = Material.objects.filter(organization=request.org)
|
|
|
1418
|
+ except Exception:
|
|
|
1419
|
+ pass
|
|
|
1420
|
+ if form.is_valid():
|
|
|
1421
|
+ inst = form.save(commit=False)
|
|
|
1422
|
+ if getattr(request, "org", None) is not None:
|
|
|
1423
|
+ inst.organization = request.org
|
|
|
1424
|
+ inst.save()
|
|
|
1425
|
+ messages.success(request, "Emission factor updated.")
|
|
|
1426
|
+ return redirect("recycle_core:emission_factors_list")
|
|
|
1427
|
+ else:
|
|
|
1428
|
+ messages.error(request, "Please correct the errors below.")
|
|
|
1429
|
+ else:
|
|
|
1430
|
+ form = MaterialEmissionFactorForm(instance=item)
|
|
|
1431
|
+ if getattr(request, "org", None) is not None:
|
|
|
1432
|
+ try:
|
|
|
1433
|
+ form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
|
|
|
1434
|
+ form.fields["material"].queryset = Material.objects.filter(organization=request.org)
|
|
|
1435
|
+ except Exception:
|
|
|
1436
|
+ pass
|
|
|
1437
|
+ return render(request, "recycle_core/emission_factor_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:emission_factors_list")})
|
|
|
1438
|
+
|
|
|
1439
|
+
|
|
|
1440
|
+@staff_member_required
|
|
|
1441
|
+@require_POST
|
|
|
1442
|
+def emission_factor_delete(request, pk: int):
|
|
|
1443
|
+ item = get_object_or_404(MaterialEmissionFactor, pk=pk)
|
|
|
1444
|
+ item.delete()
|
|
|
1445
|
+ messages.success(request, "Emission factor deleted.")
|
|
|
1446
|
+ return redirect("recycle_core:emission_factors_list")
|
|
930
|
1447
|
@staff_member_required
|
|
931
|
1448
|
@public_route(label="Provided Services", order=15, icon="sparkles")
|
|
932
|
1449
|
@breadcrumbs(label="Provided Services", name="rc_services")
|