Преглед изворни кода

carbon, invoice, weigh ticket

tum пре 8 месеци
родитељ
комит
d33627223e
37 измењених фајлова са 2117 додато и 133 уклоњено
  1. 13 1
      admin_frontend/templates/admin_frontend/dashboard.html
  2. 19 0
      admin_frontend/views.py
  3. 2 1
      billing/urls_admin.py
  4. 44 0
      billing/views.py
  5. 1 0
      ecoloop/settings.py
  6. 63 0
      orgs/migrations/0008_userprofile_address_line1_userprofile_address_line2_and_more.py
  7. 33 0
      orgs/migrations/0009_organization_allow_add_residual_line_and_more.py
  8. 18 0
      orgs/models.py
  9. 46 0
      public_frontend/forms.py
  10. 21 0
      public_frontend/migrations/0003_lead_created_by.py
  11. 8 0
      public_frontend/models.py
  12. 23 1
      public_frontend/templates/public_frontend/base.html
  13. 106 42
      public_frontend/templates/public_frontend/my_profile.html
  14. 62 4
      public_frontend/views.py
  15. 7 0
      recycle_core/apps.py
  16. 3 1
      recycle_core/controllers/pickup_request.py
  17. 68 0
      recycle_core/forms.py
  18. 11 0
      recycle_core/management/commands/seed_ecoloop.py
  19. 77 0
      recycle_core/migrations/0003_materialemissionfactor_carbonevent_carbonbalance.py
  20. 19 0
      recycle_core/migrations/0004_alter_weighticket_pickup.py
  21. 31 0
      recycle_core/migrations/0005_weighticket_finalized_at_weighticket_finalized_by_and_more.py
  22. 17 0
      recycle_core/migrations/0006_alter_weighticket_options.py
  23. 90 1
      recycle_core/models.py
  24. 69 21
      recycle_core/services/billing.py
  25. 170 0
      recycle_core/services/carbon.py
  26. 17 0
      recycle_core/signals.py
  27. 64 0
      recycle_core/templates/recycle_core/carbon_events_list.html
  28. 18 0
      recycle_core/templates/recycle_core/emission_factor_form.html
  29. 72 0
      recycle_core/templates/recycle_core/emission_factors_list.html
  30. 116 0
      recycle_core/templates/recycle_core/invoice_detail.html
  31. 2 1
      recycle_core/templates/recycle_core/invoices_list.html
  32. 97 23
      recycle_core/templates/recycle_core/pickup_detail.html
  33. 14 12
      recycle_core/templates/recycle_core/pickups_list.html
  34. 6 0
      recycle_core/templates/recycle_core/user_form.html
  35. 149 11
      recycle_core/templates/recycle_core/weigh_ticket_detail.html
  36. 10 0
      recycle_core/urls.py
  37. 531 14
      recycle_core/views.py

+ 13 - 1
admin_frontend/templates/admin_frontend/dashboard.html

17
     <div class="text-gray-500 text-sm">Introduction Requests</div>
17
     <div class="text-gray-500 text-sm">Introduction Requests</div>
18
     <div class="text-3xl font-bold">{{ counts.intro_requests }}</div>
18
     <div class="text-3xl font-bold">{{ counts.intro_requests }}</div>
19
   </div>
19
   </div>
20
+  <div class="bg-white rounded shadow p-4 md:col-span-3">
21
+    <div class="text-gray-500 text-sm mb-2">Carbon Summary</div>
22
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
23
+      <div>
24
+        <div class="text-gray-500 text-xs">Pending Events</div>
25
+        <div class="text-2xl font-semibold">{{ carbon.pending_events|default:0 }}</div>
26
+      </div>
27
+      <div>
28
+        <div class="text-gray-500 text-xs">Approved kgCO2e (MTD)</div>
29
+        <div class="text-2xl font-semibold">{{ carbon.approved_mtd|default:0 }}</div>
30
+      </div>
31
+    </div>
32
+  </div>
20
   </div>
33
   </div>
21
 {% endblock %}
34
 {% endblock %}
22
-

+ 19 - 0
admin_frontend/views.py

14
 from api.services.matching import rank_users_for, rank_opportunities_for
14
 from api.services.matching import rank_users_for, rank_opportunities_for
15
 from api.services.benefit import build_leaderboard_queryset
15
 from api.services.benefit import build_leaderboard_queryset
16
 from .nav import _nav_items
16
 from .nav import _nav_items
17
+from recycle_core.models import CarbonEvent, CarbonBalance
18
+from django.utils import timezone
17
 from orgs.models import OrganizationSite
19
 from orgs.models import OrganizationSite
18
 
20
 
19
 
21
 
64
 @staff_member_required
66
 @staff_member_required
65
 @public_route(label="Dashboard", order=10, icon="home")
67
 @public_route(label="Dashboard", order=10, icon="home")
66
 def dashboard(request):
68
 def dashboard(request):
69
+    org = getattr(request, "org", None)
70
+    # Carbon summaries
71
+    pending_qs = CarbonEvent.objects.filter(status=CarbonEvent.STATUS_PENDING)
72
+    if org is not None:
73
+        pending_qs = pending_qs.filter(organization=org)
74
+    pending_count = pending_qs.count()
75
+
76
+    today = timezone.now().date()
77
+    bal = CarbonBalance.objects.filter(year=today.year, month=today.month)
78
+    if org is not None:
79
+        bal = bal.filter(organization=org)
80
+    approved_mtd = sum((b.approved_kgco2e for b in bal), start=0)
81
+
67
     context = {
82
     context = {
68
         "nav": _nav_items(),
83
         "nav": _nav_items(),
69
         "counts": {
84
         "counts": {
71
             "opportunities": Opportunity.objects.count(),
86
             "opportunities": Opportunity.objects.count(),
72
             "intro_requests": IntroductionRequest.objects.count(),
87
             "intro_requests": IntroductionRequest.objects.count(),
73
         },
88
         },
89
+        "carbon": {
90
+            "pending_events": pending_count,
91
+            "approved_mtd": approved_mtd,
92
+        },
74
     }
93
     }
75
     return render(request, "admin_frontend/dashboard.html", context)
94
     return render(request, "admin_frontend/dashboard.html", context)
76
 
95
 

+ 2 - 1
billing/urls_admin.py

5
 
5
 
6
 urlpatterns = [
6
 urlpatterns = [
7
     path("invoices/", views.invoices_list, name="invoices_list"),
7
     path("invoices/", views.invoices_list, name="invoices_list"),
8
+    path("invoices/<int:pk>/", views.invoice_detail, name="invoice_detail"),
9
+    path("invoices/<int:pk>/set-status/", views.invoice_set_status, name="invoice_set_status"),
8
     path("invoices/<int:pk>/add-payment/", views.invoice_add_payment, name="invoice_add_payment"),
10
     path("invoices/<int:pk>/add-payment/", views.invoice_add_payment, name="invoice_add_payment"),
9
     path("payouts/", views.payouts_list, name="payouts_list"),
11
     path("payouts/", views.payouts_list, name="payouts_list"),
10
     path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
12
     path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
11
 ]
13
 ]
12
-

+ 44 - 0
billing/views.py

1
 from django.shortcuts import render, redirect, get_object_or_404
1
 from django.shortcuts import render, redirect, get_object_or_404
2
 from django.contrib.admin.views.decorators import staff_member_required
2
 from django.contrib.admin.views.decorators import staff_member_required
3
 from django.contrib import messages
3
 from django.contrib import messages
4
+from django.views.decorators.http import require_POST
4
 from django.urls import reverse
5
 from django.urls import reverse
5
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
6
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
6
 import django_filters as filters
7
 import django_filters as filters
99
     return redirect("billing:invoices_list")
100
     return redirect("billing:invoices_list")
100
 
101
 
101
 
102
 
103
+@staff_member_required
104
+@breadcrumbs(label="Invoice Detail", parent="re_invoices")
105
+def invoice_detail(request, pk: int):
106
+    inv = get_object_or_404(
107
+        Invoice.objects.select_related("organization", "customer").prefetch_related("lines", "payments"), pk=pk
108
+    )
109
+    # Scope check to current org if applicable
110
+    org = getattr(request, "org", None)
111
+    if org is not None and inv.organization_id != org.id:
112
+        messages.error(request, "Invoice not in this organization.")
113
+        return redirect("billing:invoices_list")
114
+
115
+    # Totals
116
+    payments_total = inv.payments.aggregate(s=Coalesce(Sum("amount"), Value(Decimal("0.00"))))["s"] or Decimal("0.00")
117
+    balance = (Decimal(inv.total_amount) - Decimal(payments_total)).quantize(Decimal("0.01"))
118
+
119
+    pay_form = PaymentForm()
120
+    context = {
121
+        "nav": _nav_items(),
122
+        "invoice": inv,
123
+        "payments_total": payments_total,
124
+        "balance": balance,
125
+        "pay_form": pay_form,
126
+        "status_choices": Invoice.STATUS_CHOICES,
127
+    }
128
+    return render(request, "recycle_core/invoice_detail.html", context)
129
+
130
+
131
+@staff_member_required
132
+@require_POST
133
+def invoice_set_status(request, pk: int):
134
+    inv = get_object_or_404(Invoice, pk=pk)
135
+    new_status = (request.POST.get("status") or "").strip()
136
+    valid = {k for k, _ in Invoice.STATUS_CHOICES}
137
+    if new_status not in valid:
138
+        messages.error(request, "Invalid status selection.")
139
+        return redirect("billing:invoice_detail", pk=inv.id)
140
+    inv.status = new_status
141
+    inv.save(update_fields=["status", "updated_at"])
142
+    messages.success(request, f"Invoice status updated to '{new_status}'.")
143
+    return redirect("billing:invoice_detail", pk=inv.id)
144
+
145
+
102
 # Payouts UI + CSV ----------------------------------------------------------
146
 # Payouts UI + CSV ----------------------------------------------------------
103
 
147
 
104
 @staff_member_required
148
 @staff_member_required

+ 1 - 0
ecoloop/settings.py

55
     'django.contrib.sessions',
55
     'django.contrib.sessions',
56
     'django.contrib.messages',
56
     'django.contrib.messages',
57
     'django.contrib.sites',
57
     'django.contrib.sites',
58
+    'django.contrib.humanize',
58
     'rest_framework',
59
     'rest_framework',
59
     'djoser',
60
     'djoser',
60
     'drf_spectacular',
61
     'drf_spectacular',

+ 63 - 0
orgs/migrations/0008_userprofile_address_line1_userprofile_address_line2_and_more.py

1
+# Generated by Django 4.2.24 on 2025-09-29 10:03
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('orgs', '0007_userprofile_my_photo'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='userprofile',
15
+            name='address_line1',
16
+            field=models.CharField(blank=True, max_length=255),
17
+        ),
18
+        migrations.AddField(
19
+            model_name='userprofile',
20
+            name='address_line2',
21
+            field=models.CharField(blank=True, max_length=255),
22
+        ),
23
+        migrations.AddField(
24
+            model_name='userprofile',
25
+            name='city',
26
+            field=models.CharField(blank=True, max_length=128),
27
+        ),
28
+        migrations.AddField(
29
+            model_name='userprofile',
30
+            name='country',
31
+            field=models.CharField(blank=True, max_length=64),
32
+        ),
33
+        migrations.AddField(
34
+            model_name='userprofile',
35
+            name='department',
36
+            field=models.CharField(blank=True, max_length=128),
37
+        ),
38
+        migrations.AddField(
39
+            model_name='userprofile',
40
+            name='job_title',
41
+            field=models.CharField(blank=True, max_length=128),
42
+        ),
43
+        migrations.AddField(
44
+            model_name='userprofile',
45
+            name='phone',
46
+            field=models.CharField(blank=True, max_length=32),
47
+        ),
48
+        migrations.AddField(
49
+            model_name='userprofile',
50
+            name='postal_code',
51
+            field=models.CharField(blank=True, max_length=32),
52
+        ),
53
+        migrations.AddField(
54
+            model_name='userprofile',
55
+            name='preferred_language',
56
+            field=models.CharField(blank=True, max_length=10),
57
+        ),
58
+        migrations.AddField(
59
+            model_name='userprofile',
60
+            name='state',
61
+            field=models.CharField(blank=True, max_length=128),
62
+        ),
63
+    ]

+ 33 - 0
orgs/migrations/0009_organization_allow_add_residual_line_and_more.py

1
+# Generated by Django 4.2.24 on 2025-09-30 14:36
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('orgs', '0008_userprofile_address_line1_userprofile_address_line2_and_more'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='organization',
15
+            name='allow_add_residual_line',
16
+            field=models.BooleanField(default=True),
17
+        ),
18
+        migrations.AddField(
19
+            model_name='organization',
20
+            name='allow_adjust_net_to_lines',
21
+            field=models.BooleanField(default=True),
22
+        ),
23
+        migrations.AddField(
24
+            model_name='organization',
25
+            name='ticket_tolerance_abs_kg',
26
+            field=models.DecimalField(decimal_places=3, default=0.5, max_digits=10),
27
+        ),
28
+        migrations.AddField(
29
+            model_name='organization',
30
+            name='ticket_tolerance_pct',
31
+            field=models.DecimalField(decimal_places=2, default=1.0, max_digits=5),
32
+        ),
33
+    ]

+ 18 - 0
orgs/models.py

40
     )
40
     )
41
     currency_code = models.CharField(max_length=8, choices=CURRENCY_CHOICES, default="USD")
41
     currency_code = models.CharField(max_length=8, choices=CURRENCY_CHOICES, default="USD")
42
 
42
 
43
+    # Weigh ticket reconciliation settings
44
+    ticket_tolerance_abs_kg = models.DecimalField(max_digits=10, decimal_places=3, default=0.500)
45
+    ticket_tolerance_pct = models.DecimalField(max_digits=5, decimal_places=2, default=1.00)
46
+    allow_adjust_net_to_lines = models.BooleanField(default=True)
47
+    allow_add_residual_line = models.BooleanField(default=True)
48
+
43
     def __str__(self) -> str:
49
     def __str__(self) -> str:
44
         return self.name
50
         return self.name
45
 
51
 
70
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="users")
76
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="users")
71
     role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_MANAGER)
77
     role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_MANAGER)
72
     my_photo = models.ImageField(upload_to="user_photos/", blank=True, null=True)
78
     my_photo = models.ImageField(upload_to="user_photos/", blank=True, null=True)
79
+    # Contact and profile details
80
+    phone = models.CharField(max_length=32, blank=True)
81
+    job_title = models.CharField(max_length=128, blank=True)
82
+    department = models.CharField(max_length=128, blank=True)
83
+    preferred_language = models.CharField(max_length=10, blank=True)
84
+    # Address fields
85
+    address_line1 = models.CharField(max_length=255, blank=True)
86
+    address_line2 = models.CharField(max_length=255, blank=True)
87
+    city = models.CharField(max_length=128, blank=True)
88
+    state = models.CharField(max_length=128, blank=True)
89
+    postal_code = models.CharField(max_length=32, blank=True)
90
+    country = models.CharField(max_length=64, blank=True)
73
 
91
 
74
     def __str__(self) -> str:
92
     def __str__(self) -> str:
75
         return f"{self.user.username} ({self.role})"
93
         return f"{self.user.username} ({self.role})"

+ 46 - 0
public_frontend/forms.py

2
 
2
 
3
 from django import forms
3
 from django import forms
4
 from django.contrib.auth import get_user_model
4
 from django.contrib.auth import get_user_model
5
+from django.conf import settings
5
 from django.core.exceptions import ValidationError
6
 from django.core.exceptions import ValidationError
6
 from crispy_forms.helper import FormHelper
7
 from crispy_forms.helper import FormHelper
7
 from crispy_forms.layout import Layout, Field
8
 from crispy_forms.layout import Layout, Field
184
         if commit:
185
         if commit:
185
             instance.save()
186
             instance.save()
186
         return instance
187
         return instance
188
+
189
+
190
+class PublicUserProfileExtraForm(forms.ModelForm):
191
+    class Meta:
192
+        model = UserProfile
193
+        fields = [
194
+            "phone",
195
+            "job_title",
196
+            "department",
197
+            "preferred_language",
198
+            "address_line1",
199
+            "address_line2",
200
+            "city",
201
+            "state",
202
+            "postal_code",
203
+            "country",
204
+        ]
205
+
206
+    def __init__(self, *args, **kwargs):
207
+        super().__init__(*args, **kwargs)
208
+        # Render preferred_language as a dropdown of available LANGUAGES
209
+        try:
210
+            lang_choices = list(getattr(settings, "LANGUAGES", []))
211
+        except Exception:
212
+            lang_choices = []
213
+        if lang_choices:
214
+            self.fields["preferred_language"] = forms.ChoiceField(
215
+                choices=[("", "—")] + lang_choices,
216
+                required=False,
217
+                label=self.fields.get("preferred_language").label if self.fields.get("preferred_language") else "Preferred language",
218
+            )
219
+        self.helper = FormHelper()
220
+        self.helper.form_tag = False
221
+        self.helper.layout = Layout(
222
+            Field("phone"),
223
+            Field("job_title"),
224
+            Field("department"),
225
+            Field("preferred_language"),
226
+            Field("address_line1"),
227
+            Field("address_line2"),
228
+            Field("city"),
229
+            Field("state"),
230
+            Field("postal_code"),
231
+            Field("country"),
232
+        )

+ 21 - 0
public_frontend/migrations/0003_lead_created_by.py

1
+# Generated by Django 4.2.24 on 2025-09-29 09:48
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+        ('public_frontend', '0002_alter_lead_source'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.AddField(
17
+            model_name='lead',
18
+            name='created_by',
19
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads_created', to=settings.AUTH_USER_MODEL),
20
+        ),
21
+    ]

+ 8 - 0
public_frontend/models.py

1
 from __future__ import annotations
1
 from __future__ import annotations
2
 
2
 
3
 from django.db import models
3
 from django.db import models
4
+from django.conf import settings
4
 from orgs.models import Organization
5
 from orgs.models import Organization
5
 
6
 
6
 
7
 
18
     message = models.TextField(blank=True)
19
     message = models.TextField(blank=True)
19
     source = models.CharField(max_length=64, blank=True, choices=LeadSource.choices)
20
     source = models.CharField(max_length=64, blank=True, choices=LeadSource.choices)
20
     created_at = models.DateTimeField(auto_now_add=True)
21
     created_at = models.DateTimeField(auto_now_add=True)
22
+    created_by = models.ForeignKey(
23
+        settings.AUTH_USER_MODEL,
24
+        on_delete=models.SET_NULL,
25
+        null=True,
26
+        blank=True,
27
+        related_name="leads_created",
28
+    )
21
 
29
 
22
     def __str__(self) -> str:  # pragma: no cover
30
     def __str__(self) -> str:  # pragma: no cover
23
         return f"Lead {self.name} ({self.organization.code})"
31
         return f"Lead {self.name} ({self.organization.code})"

+ 23 - 1
public_frontend/templates/public_frontend/base.html

49
     {% if messages %}
49
     {% if messages %}
50
       <div id="messages" class="max-w-3xl mx-auto mb-4 space-y-2">
50
       <div id="messages" class="max-w-3xl mx-auto mb-4 space-y-2">
51
         {% for message in messages %}
51
         {% for message in messages %}
52
-          <div class="rounded border px-4 py-3 shadow {% if 'success' in message.tags %}bg-emerald-50 border-emerald-200 text-emerald-800{% elif 'error' in message.tags %}bg-rose-50 border-rose-200 text-rose-800{% elif 'warning' in message.tags %}bg-amber-50 border-amber-200 text-amber-900{% else %}bg-sky-50 border-sky-200 text-sky-900{% endif %}">
52
+          <div class="msg rounded border px-4 py-3 shadow {% if 'success' in message.tags %}bg-emerald-50 border-emerald-200 text-emerald-800{% elif 'error' in message.tags %}bg-rose-50 border-rose-200 text-rose-800{% elif 'warning' in message.tags %}bg-amber-50 border-amber-200 text-amber-900{% else %}bg-sky-50 border-sky-200 text-sky-900{% endif %}">
53
             {{ message }}
53
             {{ message }}
54
           </div>
54
           </div>
55
         {% endfor %}
55
         {% endfor %}
63
       <a href="/webadmin/" class="hover:underline">{% trans "Staff Login" %}</a>
63
       <a href="/webadmin/" class="hover:underline">{% trans "Staff Login" %}</a>
64
     </div>
64
     </div>
65
   </footer>
65
   </footer>
66
+  <script>
67
+    // Fade-in then auto-fade-out system messages
68
+    window.addEventListener('DOMContentLoaded', () => {
69
+      const items = document.querySelectorAll('#messages .msg');
70
+      items.forEach((el, idx) => {
71
+        el.style.opacity = '0';
72
+        el.style.transform = 'translateY(-4px)';
73
+        el.style.transition = 'opacity 300ms ease, transform 300ms ease';
74
+        // Staggered fade-in
75
+        setTimeout(() => {
76
+          el.style.opacity = '1';
77
+          el.style.transform = 'translateY(0)';
78
+        }, 30 + idx * 50);
79
+        // Auto fade-out and remove
80
+        setTimeout(() => {
81
+          el.style.opacity = '0';
82
+          el.style.transform = 'translateY(-4px)';
83
+          setTimeout(() => { try { el.remove(); } catch(e) {} }, 400);
84
+        }, 5000 + idx * 120);
85
+      });
86
+    });
87
+  </script>
66
 </body>
88
 </body>
67
 </html>
89
 </html>

+ 106 - 42
public_frontend/templates/public_frontend/my_profile.html

2
 {% load crispy_forms_tags i18n %}
2
 {% load crispy_forms_tags i18n %}
3
 {% block title %}{% trans "My Profile" %}{% endblock %}
3
 {% block title %}{% trans "My Profile" %}{% endblock %}
4
 {% block content %}
4
 {% block content %}
5
-<div class="max-w-3xl mx-auto">
5
+<div class="max-w-5xl mx-auto" x-data="{ section: 'profile' }" x-init="section = (location.hash==='\#history' ? 'history' : 'profile')">
6
   <h1 class="text-2xl font-semibold mb-4">{% trans "My Profile" %}</h1>
6
   <h1 class="text-2xl font-semibold mb-4">{% trans "My Profile" %}</h1>
7
-  <form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4">
8
-    {% csrf_token %}
9
-    <div class="grid grid-cols-1 gap-6">
10
-      <div>
11
-        <h2 class="text-base font-semibold mb-2">{% trans "Account" %}</h2>
12
-        <div class="grid md:grid-cols-2 gap-4 mb-2">
13
-          <div>
14
-            <label class="block text-sm font-medium mb-1">{% trans "Username" %}</label>
15
-            <input type="text" value="{{ username_value }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
7
+  <div class="grid grid-cols-1 md:grid-cols-12 gap-6">
8
+    <aside class="md:col-span-3">
9
+      <nav class="bg-white rounded shadow p-4">
10
+        <div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">{% trans "Navigation" %}</div>
11
+        <ul class="space-y-1">
12
+          <li>
13
+            <a href="#profile" @click.prevent="section='profile'" :class="section==='profile' ? 'bg-blue-50 text-blue-700' : 'text-gray-700'" class="block rounded px-3 py-2 hover:bg-gray-50">{% trans "Profile" %}</a>
14
+          </li>
15
+          <li>
16
+            <a href="#history" @click.prevent="section='history'" :class="section==='history' ? 'bg-blue-50 text-blue-700' : 'text-gray-700'" class="block rounded px-3 py-2 hover:bg-gray-50">{% trans "Request History" %}</a>
17
+          </li>
18
+        </ul>
19
+      </nav>
20
+    </aside>
21
+    <section class="md:col-span-9 space-y-6">
22
+      <div x-show="section==='profile'" x-cloak>
23
+        {% if carbon %}
24
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
25
+          <div class="bg-white rounded shadow p-4">
26
+            <div class="text-gray-500 text-xs">Pending Carbon Events</div>
27
+            <div class="text-2xl font-semibold">{{ carbon.pending_events }}</div>
16
           </div>
28
           </div>
17
-          <div>
18
-            <label class="block text-sm font-medium mb-1">{% trans "Role" %}</label>
19
-            <input type="text" value="{{ role_label }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
29
+          <div class="bg-white rounded shadow p-4">
30
+            <div class="text-gray-500 text-xs">Approved kgCO2e (MTD)</div>
31
+            <div class="text-2xl font-semibold">{{ carbon.approved_mtd }}</div>
20
           </div>
32
           </div>
21
         </div>
33
         </div>
22
-        {{ form_user|crispy }}
23
-      </div>
24
-      {% if has_profile %}
25
-      <div>
26
-        <h2 class="text-base font-semibold mb-2">{% trans "Profile Photo" %}</h2>
27
-        <div class="flex items-start gap-4">
28
-          <div>
29
-            <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
30
-            {% if not request.user.recycle_profile.my_photo %}
31
-              <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
34
+        {% endif %}
35
+        <form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4">
36
+          {% csrf_token %}
37
+          <div class="grid grid-cols-1 gap-6">
38
+            <div>
39
+              <h2 class="text-base font-semibold mb-2">{% trans "Account" %}</h2>
40
+              <div class="grid md:grid-cols-2 gap-4 mb-2">
41
+                <div>
42
+                  <label class="block text-sm font-medium mb-1">{% trans "Username" %}</label>
43
+                  <input type="text" value="{{ username_value }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
44
+                </div>
45
+                <div>
46
+                  <label class="block text-sm font-medium mb-1">{% trans "Role" %}</label>
47
+                  <input type="text" value="{{ role_label }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
48
+                </div>
49
+              </div>
50
+              {{ form_user|crispy }}
51
+            </div>
52
+            {% if has_profile %}
53
+            <div>
54
+              <h2 class="text-base font-semibold mb-2">{% trans "Profile Photo" %}</h2>
55
+              <div class="flex items-start gap-4">
56
+                <div>
57
+                  <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
58
+                  {% if not request.user.recycle_profile.my_photo %}
59
+                    <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
60
+                  {% endif %}
61
+                </div>
62
+                <div class="flex-1">
63
+                  {{ form_photo.my_photo }}
64
+                  <div class="flex items-center gap-3">
65
+                    <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
66
+                    {% if form_photo.remove_photo %}
67
+                      <label class="inline-flex items-center gap-2 text-sm text-gray-700">
68
+                        {{ form_photo.remove_photo }} Remove current photo
69
+                      </label>
70
+                    {% endif %}
71
+                  </div>
72
+                  <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
73
+                  <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
74
+                </div>
75
+              </div>
76
+            </div>
77
+            <div>
78
+              <h2 class="text-base font-semibold mb-2">{% trans "Contact & Address" %}</h2>
79
+              {{ form_extras|crispy }}
80
+            </div>
32
             {% endif %}
81
             {% endif %}
33
           </div>
82
           </div>
34
-          <div class="flex-1">
35
-            {{ form_photo.my_photo }}
36
-            <div class="flex items-center gap-3">
37
-              <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
38
-              {% if form_photo.remove_photo %}
39
-                <label class="inline-flex items-center gap-2 text-sm text-gray-700">
40
-                  {{ form_photo.remove_photo }} Remove current photo
41
-                </label>
42
-              {% endif %}
43
-            </div>
44
-            <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
45
-            <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
83
+          <div class="mt-4">
84
+            <button class="btn-primary" type="submit">{% trans "Save Changes" %}</button>
85
+            <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
86
+          </div>
87
+        </form>
88
+      </div>
89
+      <div x-show="section==='history'" x-cloak>
90
+        <div class="bg-white rounded shadow p-4">
91
+          <h2 class="text-base font-semibold mb-3">{% trans "Request History" %}</h2>
92
+          {% if request_history %}
93
+          <div class="overflow-x-auto">
94
+            <table class="min-w-full text-sm">
95
+              <thead>
96
+                <tr class="text-left text-gray-600">
97
+                  <th class="px-3 py-2">{% trans "Date" %}</th>
98
+                  <th class="px-3 py-2">{% trans "Subject" %}</th>
99
+                  <th class="px-3 py-2">{% trans "Organization" %}</th>
100
+                </tr>
101
+              </thead>
102
+              <tbody class="bg-white">
103
+                {% for it in request_history %}
104
+                <tr class="border-t">
105
+                  <td class="px-3 py-2 whitespace-nowrap">{{ it.created_at|date:"Y-m-d H:i" }}</td>
106
+                  <td class="px-3 py-2">{{ it.subject|default:"Pickup Request" }}</td>
107
+                  <td class="px-3 py-2">{{ it.organization.name }}</td>
108
+                </tr>
109
+                {% endfor %}
110
+              </tbody>
111
+            </table>
46
           </div>
112
           </div>
113
+          {% else %}
114
+            <div class="text-sm text-gray-600">{% trans "No requests found for your account email." %}</div>
115
+          {% endif %}
47
         </div>
116
         </div>
48
       </div>
117
       </div>
49
-      {% endif %}
50
-    </div>
51
-    <div class="mt-4">
52
-      <button class="btn-primary" type="submit">{% trans "Save Changes" %}</button>
53
-      <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
54
-    </div>
55
-  </form>
118
+    </section>
119
+  </div>
56
 </div>
120
 </div>
57
 <script>
121
 <script>
58
   (function(){
122
   (function(){

+ 62 - 4
public_frontend/views.py

10
 from recycle_core.models import Material, ScrapListing, PickupOrder
10
 from recycle_core.models import Material, ScrapListing, PickupOrder
11
 from cms.models import Post, PostCategory
11
 from cms.models import Post, PostCategory
12
 
12
 
13
-from .forms import PickupRequestForm, ContactForm, RegistrationForm, PublicUserForm, PublicUserPhotoForm
13
+from .forms import (
14
+    PickupRequestForm,
15
+    ContactForm,
16
+    RegistrationForm,
17
+    PublicUserForm,
18
+    PublicUserPhotoForm,
19
+    PublicUserProfileExtraForm,
20
+)
14
 from .models import Lead
21
 from .models import Lead
15
 from recycle_core.controllers.pickup_request import (
22
 from recycle_core.controllers.pickup_request import (
16
     PickupRequestController,
23
     PickupRequestController,
107
 
114
 
108
 def pickup_request(request):
115
 def pickup_request(request):
109
     org = getattr(request, "org", None)
116
     org = getattr(request, "org", None)
110
-    form = PickupRequestForm(request.POST or None, request.FILES or None)
117
+    # Prefill form for authenticated users
118
+    initial = {}
119
+    if request.user.is_authenticated:
120
+        u = request.user
121
+        full_name = (getattr(u, "get_full_name", lambda: "")() or "").strip()
122
+        initial["name"] = full_name or u.username
123
+        if getattr(u, "email", ""):
124
+            initial["email"] = u.email
125
+    form = PickupRequestForm(request.POST or None, request.FILES or None, initial=initial)
111
     if request.method == "POST":
126
     if request.method == "POST":
112
         if not org:
127
         if not org:
113
             messages.error(request, "Organization context missing.")
128
             messages.error(request, "Organization context missing.")
123
                 materials=form.cleaned_data.get("materials", ""),
138
                 materials=form.cleaned_data.get("materials", ""),
124
                 preferred_at=form.cleaned_data.get("preferred_at"),
139
                 preferred_at=form.cleaned_data.get("preferred_at"),
125
                 files=request.FILES.getlist("photos") if hasattr(request, "FILES") and "photos" in request.FILES else [],
140
                 files=request.FILES.getlist("photos") if hasattr(request, "FILES") and "photos" in request.FILES else [],
141
+                created_by=request.user if request.user.is_authenticated else None,
126
             )
142
             )
127
             result = ctrl.submit(data)
143
             result = ctrl.submit(data)
128
             if result.ok:
144
             if result.ok:
151
                 subject=form.cleaned_data.get("subject", ""),
167
                 subject=form.cleaned_data.get("subject", ""),
152
                 message=form.cleaned_data.get("message", ""),
168
                 message=form.cleaned_data.get("message", ""),
153
                 source="contact",
169
                 source="contact",
170
+                created_by=request.user if request.user.is_authenticated else None,
154
             )
171
             )
155
             messages.success(request, "Thanks! We will get back to you shortly.")
172
             messages.success(request, "Thanks! We will get back to you shortly.")
156
             return redirect("public_frontend:home")
173
             return redirect("public_frontend:home")
230
 
247
 
231
 from django.contrib.auth.decorators import login_required
248
 from django.contrib.auth.decorators import login_required
232
 from django.contrib.auth import get_user_model
249
 from django.contrib.auth import get_user_model
250
+from django.utils import timezone
251
+from recycle_core.models import CarbonEvent, CarbonBalance
233
 
252
 
234
 
253
 
235
 @login_required
254
 @login_required
241
     if request.method == "POST":
260
     if request.method == "POST":
242
         form_user = PublicUserForm(request.POST, instance=user)
261
         form_user = PublicUserForm(request.POST, instance=user)
243
         form_photo = PublicUserPhotoForm(request.POST, request.FILES, instance=profile) if profile else None
262
         form_photo = PublicUserPhotoForm(request.POST, request.FILES, instance=profile) if profile else None
263
+        form_extras = PublicUserProfileExtraForm(request.POST, instance=profile) if profile else None
244
         ok_user = form_user.is_valid()
264
         ok_user = form_user.is_valid()
245
         ok_photo = True if form_photo is None else form_photo.is_valid()
265
         ok_photo = True if form_photo is None else form_photo.is_valid()
246
-        if ok_user and ok_photo:
266
+        ok_extras = True if form_extras is None else form_extras.is_valid()
267
+        if ok_user and ok_photo and ok_extras:
247
             form_user.save()
268
             form_user.save()
248
             if form_photo is not None:
269
             if form_photo is not None:
249
                 form_photo.save()
270
                 form_photo.save()
271
+            if form_extras is not None:
272
+                form_extras.save()
250
             messages.success(request, "Profile updated.")
273
             messages.success(request, "Profile updated.")
251
             return redirect(reverse("public_frontend:my_profile"))
274
             return redirect(reverse("public_frontend:my_profile"))
252
         messages.error(request, "Please correct the errors below.")
275
         messages.error(request, "Please correct the errors below.")
253
     else:
276
     else:
254
         form_user = PublicUserForm(instance=user)
277
         form_user = PublicUserForm(instance=user)
255
         form_photo = PublicUserPhotoForm(instance=profile) if profile else None
278
         form_photo = PublicUserPhotoForm(instance=profile) if profile else None
279
+        form_extras = PublicUserProfileExtraForm(instance=profile) if profile else None
280
+
281
+    # Request history (pickup requests submitted via public form, matched by email)
282
+    history = []
283
+    try:
284
+        email = (user.email or "").strip()
285
+        if email:
286
+            qs = Lead.objects.filter(email__iexact=email, source="pickup_request").order_by("-created_at")
287
+            org = getattr(request, "org", None)
288
+            if org is not None:
289
+                qs = qs.filter(organization=org)
290
+            history = list(qs[:20])
291
+    except Exception:
292
+        history = []
293
+
294
+    # Carbon summary for user's organization
295
+    org = getattr(request, "org", None) or getattr(profile, "organization", None)
296
+    carbon = None
297
+    if org is not None:
298
+        today = timezone.now().date()
299
+        bal = CarbonBalance.objects.filter(organization=org, year=today.year, month=today.month).first()
300
+        pending = CarbonEvent.objects.filter(organization=org, status=CarbonEvent.STATUS_PENDING).count()
301
+        carbon = {
302
+            "pending_events": pending,
303
+            "approved_mtd": bal.approved_kgco2e if bal else 0,
304
+        }
256
 
305
 
257
     return render(
306
     return render(
258
         request,
307
         request,
259
         "public_frontend/my_profile.html",
308
         "public_frontend/my_profile.html",
260
-        {"form_user": form_user, "form_photo": form_photo, "has_profile": bool(profile), "username_value": user.username, "role_label": role_label},
309
+        {
310
+            "form_user": form_user,
311
+            "form_photo": form_photo,
312
+            "form_extras": form_extras,
313
+            "has_profile": bool(profile),
314
+            "username_value": user.username,
315
+            "role_label": role_label,
316
+            "request_history": history,
317
+            "carbon": carbon,
318
+        },
261
     )
319
     )

+ 7 - 0
recycle_core/apps.py

4
 class RecycleCoreConfig(AppConfig):
4
 class RecycleCoreConfig(AppConfig):
5
     default_auto_field = 'django.db.models.BigAutoField'
5
     default_auto_field = 'django.db.models.BigAutoField'
6
     name = 'recycle_core'
6
     name = 'recycle_core'
7
+
8
+    def ready(self):  # pragma: no cover - side effects only
9
+        try:
10
+            from . import signals  # noqa: F401
11
+        except Exception:
12
+            # Avoid crashing app loading if migrations incomplete
13
+            pass

+ 3 - 1
recycle_core/controllers/pickup_request.py

2
 
2
 
3
 from dataclasses import dataclass
3
 from dataclasses import dataclass
4
 from typing import Iterable, List, Optional
4
 from typing import Iterable, List, Optional
5
+from django.contrib.auth import get_user_model
5
 from django.contrib.contenttypes.models import ContentType
6
 from django.contrib.contenttypes.models import ContentType
6
 from django.utils import timezone
7
 from django.utils import timezone
7
 
8
 
19
     materials: str = ""
20
     materials: str = ""
20
     preferred_at: Optional[timezone.datetime] = None
21
     preferred_at: Optional[timezone.datetime] = None
21
     files: Optional[Iterable] = None  # iterable of UploadedFile
22
     files: Optional[Iterable] = None  # iterable of UploadedFile
23
+    created_by: Optional[object] = None  # User, but avoid circular import typing
22
 
24
 
23
 
25
 
24
 @dataclass
26
 @dataclass
56
                 subject="Pickup Request",
58
                 subject="Pickup Request",
57
                 message=message,
59
                 message=message,
58
                 source="pickup_request",
60
                 source="pickup_request",
61
+                created_by=data.created_by if getattr(data, "created_by", None) else None,
59
             )
62
             )
60
 
63
 
61
             # Attach uploaded files as Documents linked to the Lead
64
             # Attach uploaded files as Documents linked to the Lead
77
 
80
 
78
         except Exception as e:
81
         except Exception as e:
79
             return PickupRequestResult(ok=False, error=str(e), document_ids=[])
82
             return PickupRequestResult(ok=False, error=str(e), document_ids=[])
80
-

+ 68 - 0
recycle_core/forms.py

12
     ProvidedService,
12
     ProvidedService,
13
     Customer,
13
     Customer,
14
     CustomerSite,
14
     CustomerSite,
15
+    MaterialEmissionFactor,
16
+    PickupItem,
15
 )
17
 )
16
 from markdownfield.widgets import MDEWidget
18
 from markdownfield.widgets import MDEWidget
17
 from orgs.models import UserProfile
19
 from orgs.models import UserProfile
251
         if gross is not None and tare is not None and net is None:
253
         if gross is not None and tare is not None and net is None:
252
             cleaned["net_weight"] = (Decimal(gross) - Decimal(tare)).quantize(Decimal("0.001"))
254
             cleaned["net_weight"] = (Decimal(gross) - Decimal(tare)).quantize(Decimal("0.001"))
253
         return cleaned
255
         return cleaned
256
+
257
+
258
+class WeighLineForm(forms.Form):
259
+    material = forms.ModelChoiceField(queryset=Material.objects.none())
260
+    quantity = forms.DecimalField(max_digits=12, decimal_places=3)
261
+    unit = forms.ChoiceField(choices=(), required=True)
262
+
263
+    def __init__(self, *args, **kwargs):
264
+        org = kwargs.pop("organization", None)
265
+        super().__init__(*args, **kwargs)
266
+        from .models import Material as Mat
267
+        self.fields["unit"].choices = Mat.UNIT_CHOICES
268
+        # Scope materials by organization if provided
269
+        if org is not None:
270
+            self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name")
271
+        else:
272
+            self.fields["material"].queryset = Material.objects.all().order_by("name")
273
+
274
+
275
+class MaterialEmissionFactorForm(forms.ModelForm):
276
+    class Meta:
277
+        model = MaterialEmissionFactor
278
+        fields = [
279
+            "organization",
280
+            "material",
281
+            "unit",
282
+            "kgco2e_per_unit",
283
+            "source",
284
+            "effective_from",
285
+            "effective_to",
286
+        ]
287
+
288
+
289
+class PickupItemForm(forms.Form):
290
+    material = forms.ModelChoiceField(queryset=Material.objects.none())
291
+    estimated_qty = forms.DecimalField(max_digits=12, decimal_places=3, required=False)
292
+    unit = forms.ChoiceField(choices=(), required=True)
293
+
294
+    def __init__(self, *args, **kwargs):
295
+        org = kwargs.pop("organization", None)
296
+        super().__init__(*args, **kwargs)
297
+        from .models import Material as Mat
298
+        self.fields["unit"].choices = Mat.UNIT_CHOICES
299
+        if org is not None:
300
+            self.fields["material"].queryset = Material.objects.filter(organization=org).order_by("name")
301
+        else:
302
+            self.fields["material"].queryset = Material.objects.all().order_by("name")
303
+
304
+
305
+class WeighTicketWeightsForm(forms.Form):
306
+    gross_weight = forms.DecimalField(max_digits=12, decimal_places=3)
307
+    tare_weight = forms.DecimalField(max_digits=12, decimal_places=3)
308
+    unit = forms.ChoiceField(choices=(), required=True)
309
+
310
+    def __init__(self, *args, **kwargs):
311
+        super().__init__(*args, **kwargs)
312
+        from .models import Material as Mat
313
+        self.fields["unit"].choices = Mat.UNIT_CHOICES
314
+
315
+    def clean(self):
316
+        cleaned = super().clean()
317
+        g = cleaned.get("gross_weight")
318
+        t = cleaned.get("tare_weight")
319
+        if g is not None and t is not None and g < t:
320
+            self.add_error("gross_weight", "Gross must be greater than or equal to tare")
321
+        return cleaned

+ 11 - 0
recycle_core/management/commands/seed_ecoloop.py

25
     ScrapBid,
25
     ScrapBid,
26
     ScrapListingInvite,
26
     ScrapListingInvite,
27
     ScrapAward,
27
     ScrapAward,
28
+    MaterialEmissionFactor,
28
 )
29
 )
29
 from recycle_core.services.billing import generate_invoice_for_pickup
30
 from recycle_core.services.billing import generate_invoice_for_pickup
30
 
31
 
131
         can, _ = Material.objects.get_or_create(organization=org, category="Metals", name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
132
         can, _ = Material.objects.get_or_create(organization=org, category="Metals", name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
132
         cardboard, _ = Material.objects.get_or_create(organization=org, category="Paper", name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
133
         cardboard, _ = Material.objects.get_or_create(organization=org, category="Paper", name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
133
 
134
 
135
+        # Emission factors (demo values)
136
+        MaterialEmissionFactor.objects.get_or_create(
137
+            organization=org, material=pet, unit=Material.UNIT_KG,
138
+            defaults={"kgco2e_per_unit": Decimal("0.050"), "source": "Demo factor"}
139
+        )
140
+        MaterialEmissionFactor.objects.get_or_create(
141
+            organization=org, material=can, unit=Material.UNIT_KG,
142
+            defaults={"kgco2e_per_unit": Decimal("0.150"), "source": "Demo factor"}
143
+        )
144
+
134
         # Price list
145
         # Price list
135
         pl, _ = PriceList.objects.get_or_create(
146
         pl, _ = PriceList.objects.get_or_create(
136
             organization=org,
147
             organization=org,

+ 77 - 0
recycle_core/migrations/0003_materialemissionfactor_carbonevent_carbonbalance.py

1
+# Generated by Django 4.2.24 on 2025-09-30 08:47
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+import django.utils.timezone
7
+
8
+
9
+class Migration(migrations.Migration):
10
+
11
+    dependencies = [
12
+        ('orgs', '0008_userprofile_address_line1_userprofile_address_line2_and_more'),
13
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+        ('recycle_core', '0002_alter_pickuporder_options_alter_scraplisting_options_and_more'),
15
+    ]
16
+
17
+    operations = [
18
+        migrations.CreateModel(
19
+            name='MaterialEmissionFactor',
20
+            fields=[
21
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+                ('created_at', models.DateTimeField(auto_now_add=True)),
23
+                ('updated_at', models.DateTimeField(auto_now=True)),
24
+                ('unit', models.CharField(choices=[('kg', 'Kilogram'), ('lb', 'Pound'), ('pcs', 'Pieces')], default='kg', max_length=8)),
25
+                ('kgco2e_per_unit', models.DecimalField(decimal_places=6, max_digits=16)),
26
+                ('source', models.CharField(blank=True, max_length=255)),
27
+                ('effective_from', models.DateField(blank=True, null=True)),
28
+                ('effective_to', models.DateField(blank=True, null=True)),
29
+                ('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emission_factors', to='recycle_core.material')),
30
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='material_factors', to='orgs.organization')),
31
+            ],
32
+            options={
33
+                'indexes': [models.Index(fields=['organization', 'material', 'unit'], name='recycle_cor_organiz_71d624_idx')],
34
+            },
35
+        ),
36
+        migrations.CreateModel(
37
+            name='CarbonEvent',
38
+            fields=[
39
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
40
+                ('created_at', models.DateTimeField(auto_now_add=True)),
41
+                ('updated_at', models.DateTimeField(auto_now=True)),
42
+                ('quantity', models.DecimalField(decimal_places=3, max_digits=14)),
43
+                ('unit', models.CharField(choices=[('kg', 'Kilogram'), ('lb', 'Pound'), ('pcs', 'Pieces')], default='kg', max_length=8)),
44
+                ('kgco2e', models.DecimalField(decimal_places=6, max_digits=18)),
45
+                ('event_date', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
46
+                ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)),
47
+                ('approved_at', models.DateTimeField(blank=True, null=True)),
48
+                ('notes', models.TextField(blank=True)),
49
+                ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_carbon_events', to=settings.AUTH_USER_MODEL)),
50
+                ('material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recycle_core.material')),
51
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carbon_events', to='orgs.organization')),
52
+                ('weigh_line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carbon_events', to='recycle_core.weighline')),
53
+                ('weigh_ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='carbon_events', to='recycle_core.weighticket')),
54
+            ],
55
+            options={
56
+                'indexes': [models.Index(fields=['organization', 'status'], name='recycle_cor_organiz_fad901_idx'), models.Index(fields=['organization', 'event_date'], name='recycle_cor_organiz_3206eb_idx')],
57
+                'unique_together': {('weigh_line',)},
58
+            },
59
+        ),
60
+        migrations.CreateModel(
61
+            name='CarbonBalance',
62
+            fields=[
63
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
64
+                ('created_at', models.DateTimeField(auto_now_add=True)),
65
+                ('updated_at', models.DateTimeField(auto_now=True)),
66
+                ('year', models.PositiveSmallIntegerField()),
67
+                ('month', models.PositiveSmallIntegerField()),
68
+                ('events_count', models.PositiveIntegerField(default=0)),
69
+                ('approved_kgco2e', models.DecimalField(decimal_places=6, default=0, max_digits=18)),
70
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carbon_balances', to='orgs.organization')),
71
+            ],
72
+            options={
73
+                'indexes': [models.Index(fields=['organization', 'year', 'month'], name='recycle_cor_organiz_1160f2_idx')],
74
+                'unique_together': {('organization', 'year', 'month')},
75
+            },
76
+        ),
77
+    ]

+ 19 - 0
recycle_core/migrations/0004_alter_weighticket_pickup.py

1
+# Generated by Django 4.2.24 on 2025-09-30 09:56
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+
7
+class Migration(migrations.Migration):
8
+
9
+    dependencies = [
10
+        ('recycle_core', '0003_materialemissionfactor_carbonevent_carbonbalance'),
11
+    ]
12
+
13
+    operations = [
14
+        migrations.AlterField(
15
+            model_name='weighticket',
16
+            name='pickup',
17
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='weigh_tickets', to='recycle_core.pickuporder'),
18
+        ),
19
+    ]

+ 31 - 0
recycle_core/migrations/0005_weighticket_finalized_at_weighticket_finalized_by_and_more.py

1
+# Generated by Django 4.2.24 on 2025-09-30 14:36
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+        ('recycle_core', '0004_alter_weighticket_pickup'),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.AddField(
17
+            model_name='weighticket',
18
+            name='finalized_at',
19
+            field=models.DateTimeField(blank=True, null=True),
20
+        ),
21
+        migrations.AddField(
22
+            model_name='weighticket',
23
+            name='finalized_by',
24
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='finalized_tickets', to=settings.AUTH_USER_MODEL),
25
+        ),
26
+        migrations.AddField(
27
+            model_name='weighticket',
28
+            name='is_finalized',
29
+            field=models.BooleanField(default=False),
30
+        ),
31
+    ]

+ 17 - 0
recycle_core/migrations/0006_alter_weighticket_options.py

1
+# Generated by Django 4.2.24 on 2025-09-30 14:58
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('recycle_core', '0005_weighticket_finalized_at_weighticket_finalized_by_and_more'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterModelOptions(
14
+            name='weighticket',
15
+            options={'permissions': (('generate_invoice', 'Can generate invoice from weigh ticket'), ('edit_weigh_ticket', 'Can edit weigh ticket weights'))},
16
+        ),
17
+    ]

+ 90 - 1
recycle_core/models.py

244
 
244
 
245
 
245
 
246
 class WeighTicket(TimestampedModel):
246
 class WeighTicket(TimestampedModel):
247
-    pickup = models.OneToOneField(PickupOrder, on_delete=models.CASCADE, related_name="weigh_ticket")
247
+    pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="weigh_tickets")
248
     ticket_number = models.CharField(max_length=64, blank=True)
248
     ticket_number = models.CharField(max_length=64, blank=True)
249
     gross_weight = models.DecimalField(max_digits=12, decimal_places=3)
249
     gross_weight = models.DecimalField(max_digits=12, decimal_places=3)
250
     tare_weight = models.DecimalField(max_digits=12, decimal_places=3)
250
     tare_weight = models.DecimalField(max_digits=12, decimal_places=3)
252
     unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
252
     unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
253
     recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
253
     recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
254
     recorded_at = models.DateTimeField(default=timezone.now)
254
     recorded_at = models.DateTimeField(default=timezone.now)
255
+    # Finalization controls
256
+    is_finalized = models.BooleanField(default=False)
257
+    finalized_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="finalized_tickets")
258
+    finalized_at = models.DateTimeField(null=True, blank=True)
255
 
259
 
256
     class Meta:
260
     class Meta:
257
         permissions = (
261
         permissions = (
258
             ("generate_invoice", "Can generate invoice from weigh ticket"),
262
             ("generate_invoice", "Can generate invoice from weigh ticket"),
263
+            ("edit_weigh_ticket", "Can edit weigh ticket weights"),
259
         )
264
         )
260
 
265
 
261
 
266
 
388
 
393
 
389
     class Meta:
394
     class Meta:
390
         unique_together = ("listing", "invited_org")
395
         unique_together = ("listing", "invited_org")
396
+
397
+
398
+# Carbon accounting models ---------------------------------------------------
399
+
400
+
401
+class MaterialEmissionFactor(TimestampedModel):
402
+    """Emission factor for a given material per unit.
403
+
404
+    Values are in kgCO2e per given unit (default kg). Factors can be
405
+    organization-specific and time-bounded via effective dates.
406
+    """
407
+
408
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_factors")
409
+    material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="emission_factors")
410
+    unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
411
+    kgco2e_per_unit = models.DecimalField(max_digits=16, decimal_places=6)
412
+    source = models.CharField(max_length=255, blank=True)
413
+    effective_from = models.DateField(null=True, blank=True)
414
+    effective_to = models.DateField(null=True, blank=True)
415
+
416
+    class Meta:
417
+        indexes = [
418
+            models.Index(fields=["organization", "material", "unit"]),
419
+        ]
420
+        unique_together = ()
421
+
422
+    def __str__(self) -> str:  # pragma: no cover - simple display
423
+        return f"{self.material.name} {self.kgco2e_per_unit} kgCO2e/{self.unit}"
424
+
425
+
426
+class CarbonEvent(TimestampedModel):
427
+    """Calculated carbon impact event (e.g., from a weigh ticket line).
428
+
429
+    Events flow through an approvals queue before contributing to monthly
430
+    CarbonBalance aggregates.
431
+    """
432
+
433
+    STATUS_PENDING = "pending"
434
+    STATUS_APPROVED = "approved"
435
+    STATUS_REJECTED = "rejected"
436
+    STATUS_CHOICES = (
437
+        (STATUS_PENDING, "Pending"),
438
+        (STATUS_APPROVED, "Approved"),
439
+        (STATUS_REJECTED, "Rejected"),
440
+    )
441
+
442
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_events")
443
+    weigh_ticket = models.ForeignKey(WeighTicket, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
444
+    # Optional linkage to a specific line for traceability
445
+    weigh_line = models.ForeignKey(WeighLine, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
446
+    material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
447
+    quantity = models.DecimalField(max_digits=14, decimal_places=3)
448
+    unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
449
+    kgco2e = models.DecimalField(max_digits=18, decimal_places=6)
450
+    event_date = models.DateTimeField(default=timezone.now, db_index=True)
451
+
452
+    status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True)
453
+    approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="approved_carbon_events")
454
+    approved_at = models.DateTimeField(null=True, blank=True)
455
+    notes = models.TextField(blank=True)
456
+
457
+    class Meta:
458
+        indexes = [
459
+            models.Index(fields=["organization", "status"]),
460
+            models.Index(fields=["organization", "event_date"]),
461
+        ]
462
+        unique_together = ("weigh_line",)
463
+
464
+
465
+class CarbonBalance(TimestampedModel):
466
+    """Monthly aggregation of approved CarbonEvents per organization."""
467
+
468
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_balances")
469
+    year = models.PositiveSmallIntegerField()
470
+    month = models.PositiveSmallIntegerField()
471
+    events_count = models.PositiveIntegerField(default=0)
472
+    approved_kgco2e = models.DecimalField(max_digits=18, decimal_places=6, default=0)
473
+
474
+    class Meta:
475
+        unique_together = ("organization", "year", "month")
476
+        indexes = [models.Index(fields=["organization", "year", "month"])]
477
+
478
+    def __str__(self) -> str:  # pragma: no cover - simple display
479
+        return f"{self.organization.code} {self.year}-{self.month:02d}"

+ 69 - 21
recycle_core/services/billing.py

26
 
26
 
27
 
27
 
28
 def generate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
28
 def generate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
29
-    if getattr(pickup, "weigh_ticket", None) is None:
30
-        raise ValueError("Pickup has no weigh ticket")
29
+    if not pickup.weigh_tickets.exists():
30
+        raise ValueError("Pickup has no weigh tickets")
31
 
31
 
32
     # If invoice already exists and not void, return it (idempotent)
32
     # If invoice already exists and not void, return it (idempotent)
33
     existing = pickup.invoices.filter(status__in=[Invoice.STATUS_DRAFT, Invoice.STATUS_ISSUED, Invoice.STATUS_PAID]).first()
33
     existing = pickup.invoices.filter(status__in=[Invoice.STATUS_DRAFT, Invoice.STATUS_ISSUED, Invoice.STATUS_PAID]).first()
52
     total = Decimal("0.00")
52
     total = Decimal("0.00")
53
     payout_total = Decimal("0.00")
53
     payout_total = Decimal("0.00")
54
 
54
 
55
-    for line in pickup.weigh_ticket.lines.all():
56
-        sell_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_SELL)
57
-        buy_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_BUY)
58
-
59
-        if sell_price is not None and sell_price > 0:
60
-            line_total = (Decimal(line.quantity) * Decimal(sell_price)).quantize(Decimal("0.01"))
61
-            InvoiceLine.objects.create(
62
-                invoice=inv,
63
-                description=f"{line.material.name} ({line.unit})",
64
-                material=line.material,
65
-                quantity=line.quantity,
66
-                unit=line.unit,
67
-                unit_price=sell_price,
68
-                line_total=line_total,
69
-            )
70
-            total += line_total
71
-
72
-        if buy_price is not None and buy_price > 0:
73
-            payout_total += (Decimal(line.quantity) * Decimal(buy_price)).quantize(Decimal("0.01"))
55
+    for t in pickup.weigh_tickets.all():
56
+        for line in t.lines.all():
57
+            sell_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_SELL)
58
+            buy_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_BUY)
59
+
60
+            if sell_price is not None and sell_price > 0:
61
+                line_total = (Decimal(line.quantity) * Decimal(sell_price)).quantize(Decimal("0.01"))
62
+                InvoiceLine.objects.create(
63
+                    invoice=inv,
64
+                    description=f"{line.material.name} ({line.unit})",
65
+                    material=line.material,
66
+                    quantity=line.quantity,
67
+                    unit=line.unit,
68
+                    unit_price=sell_price,
69
+                    line_total=line_total,
70
+                )
71
+                total += line_total
72
+
73
+            if buy_price is not None and buy_price > 0:
74
+                payout_total += (Decimal(line.quantity) * Decimal(buy_price)).quantize(Decimal("0.01"))
74
 
75
 
75
     inv.total_amount = total
76
     inv.total_amount = total
76
     inv.issued_at = timezone.now()
77
     inv.issued_at = timezone.now()
94
     pickup.save(update_fields=["status"]) 
95
     pickup.save(update_fields=["status"]) 
95
 
96
 
96
     return inv
97
     return inv
98
+
99
+
100
+def regenerate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
101
+    existing = pickup.invoices.filter(status__in=[Invoice.STATUS_DRAFT, Invoice.STATUS_ISSUED, Invoice.STATUS_PAID]).first()
102
+    if not existing:
103
+        return generate_invoice_for_pickup(pickup)
104
+    if existing.status != Invoice.STATUS_DRAFT:
105
+        raise ValueError("Invoice not in draft status; cannot regenerate")
106
+
107
+    price_list = pickup.customer.price_list
108
+    if not price_list:
109
+        price_list = PriceList.objects.filter(organization=pickup.organization).order_by("id").first()
110
+    if not price_list:
111
+        raise ValueError("No price list found for customer or organization")
112
+
113
+    # Clear lines and rebuild
114
+    InvoiceLine.objects.filter(invoice=existing).delete()
115
+
116
+    total = Decimal("0.00")
117
+    payout_total = Decimal("0.00")
118
+    for t in pickup.weigh_tickets.all():
119
+        for line in t.lines.all():
120
+            sell_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_SELL)
121
+            buy_price = _get_price(price_list, line.material_id, line.unit, PriceListItem.DIRECTION_BUY)
122
+
123
+            if sell_price is not None and sell_price > 0:
124
+                line_total = (Decimal(line.quantity) * Decimal(sell_price)).quantize(Decimal("0.01"))
125
+                InvoiceLine.objects.create(
126
+                    invoice=existing,
127
+                    description=f"{line.material.name} ({line.unit})",
128
+                    material=line.material,
129
+                    quantity=line.quantity,
130
+                    unit=line.unit,
131
+                    unit_price=sell_price,
132
+                    line_total=line_total,
133
+                )
134
+                total += line_total
135
+
136
+            if buy_price is not None and buy_price > 0:
137
+                payout_total += (Decimal(line.quantity) * Decimal(buy_price)).quantize(Decimal("0.01"))
138
+
139
+    existing.total_amount = total
140
+    # Keep draft; do not auto issue
141
+    if not existing.currency_code:
142
+        existing.currency_code = price_list.currency_code
143
+    existing.save(update_fields=["total_amount", "currency_code", "updated_at"])
144
+    return existing

+ 170 - 0
recycle_core/services/carbon.py

1
+from __future__ import annotations
2
+
3
+from datetime import date
4
+from decimal import Decimal
5
+from typing import Iterable
6
+
7
+from django.db import transaction
8
+from django.db.models import Q
9
+from django.utils import timezone
10
+
11
+from ..models import (
12
+    Organization,
13
+    Material,
14
+    WeighTicket,
15
+    WeighLine,
16
+    MaterialEmissionFactor,
17
+    CarbonEvent,
18
+    CarbonBalance,
19
+)
20
+
21
+
22
+LB_TO_KG = Decimal("0.45359237")
23
+
24
+
25
+def _convert_qty(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
26
+    if from_unit == to_unit:
27
+        return qty
28
+    # Minimal support: lb <-> kg; pcs assumed 1:1 (no conversion)
29
+    if from_unit == Material.UNIT_LB and to_unit == Material.UNIT_KG:
30
+        return (qty * LB_TO_KG).quantize(Decimal("0.000"))
31
+    if from_unit == Material.UNIT_KG and to_unit == Material.UNIT_LB:
32
+        return (qty / LB_TO_KG).quantize(Decimal("0.000"))
33
+    return qty
34
+
35
+
36
+def get_applicable_factor(org: Organization, material: Material, unit: str, as_of: date | None = None) -> MaterialEmissionFactor | None:
37
+    """Return the most recent factor effective for the given org/material/unit.
38
+
39
+    If none matches the unit, try the material's default unit as a fallback.
40
+    """
41
+    as_of = as_of or timezone.now().date()
42
+    qs = (
43
+        MaterialEmissionFactor.objects.filter(
44
+            organization=org,
45
+            material=material,
46
+            unit=unit,
47
+        )
48
+        .filter(Q(effective_from__isnull=True) | Q(effective_from__lte=as_of))
49
+        .order_by("-effective_from", "-id")
50
+    )
51
+    fac = qs.first()
52
+    if fac:
53
+        return fac
54
+    # fallback to default unit
55
+    if unit != material.default_unit:
56
+        qs2 = (
57
+            MaterialEmissionFactor.objects.filter(
58
+                organization=org,
59
+                material=material,
60
+                unit=material.default_unit,
61
+            )
62
+            .filter(Q(effective_from__isnull=True) | Q(effective_from__lte=as_of))
63
+            .order_by("-effective_from", "-id")
64
+        )
65
+        return qs2.first()
66
+    return None
67
+
68
+
69
+@transaction.atomic
70
+def create_event_for_line(line: WeighLine) -> CarbonEvent | None:
71
+    """Create or update a pending CarbonEvent for a given weigh line.
72
+
73
+    Returns the event or None if no factor available.
74
+    """
75
+    ticket = line.ticket
76
+    org = ticket.pickup.organization
77
+    fac = get_applicable_factor(org, line.material, line.unit)
78
+    if not fac:
79
+        # Try convert to factor unit
80
+        fac = get_applicable_factor(org, line.material, fac_unit := line.material.default_unit)
81
+        if not fac:
82
+            return None
83
+        qty = _convert_qty(Decimal(line.quantity), line.unit, fac.unit)
84
+    else:
85
+        qty = Decimal(line.quantity)
86
+    kgco2e = (qty * Decimal(fac.kgco2e_per_unit)).quantize(Decimal("0.000001"))
87
+
88
+    ev, _ = CarbonEvent.objects.update_or_create(
89
+        weigh_line=line,
90
+        defaults={
91
+            "organization": org,
92
+            "weigh_ticket": ticket,
93
+            "material": line.material,
94
+            "quantity": qty,
95
+            "unit": fac.unit,
96
+            "kgco2e": kgco2e,
97
+            "event_date": timezone.now(),
98
+            "status": CarbonEvent.STATUS_PENDING,
99
+        },
100
+    )
101
+    return ev
102
+
103
+
104
+def calculate_events_for_ticket(ticket: WeighTicket) -> list[CarbonEvent]:
105
+    events: list[CarbonEvent] = []
106
+    for ln in ticket.lines.select_related("material"):
107
+        ev = create_event_for_line(ln)
108
+        if ev:
109
+            events.append(ev)
110
+    return events
111
+
112
+
113
+def approve_events(org: Organization, events: Iterable[CarbonEvent], *, approver) -> int:
114
+    """Approve events and update monthly CarbonBalance aggregates.
115
+
116
+    Returns number approved.
117
+    """
118
+    approved = 0
119
+    months: set[tuple[int, int]] = set()
120
+    now = timezone.now()
121
+    for ev in events:
122
+        if ev.organization_id != org.id:
123
+            continue
124
+        if ev.status == CarbonEvent.STATUS_APPROVED:
125
+            continue
126
+        ev.status = CarbonEvent.STATUS_APPROVED
127
+        ev.approved_by = approver
128
+        ev.approved_at = now
129
+        ev.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
130
+        months.add((ev.event_date.year, ev.event_date.month))
131
+        approved += 1
132
+    # recompute balances for affected months
133
+    for (y, m) in months:
134
+        recompute_balance_for_month(org, y, m)
135
+    return approved
136
+
137
+
138
+def recompute_balance_for_month(org: Organization, year: int, month: int) -> CarbonBalance:
139
+    qs = CarbonEvent.objects.filter(
140
+        organization=org,
141
+        status=CarbonEvent.STATUS_APPROVED,
142
+        event_date__year=year,
143
+        event_date__month=month,
144
+    )
145
+    # Avoid circular import for Sum; simple loop to sum
146
+    total_val = Decimal("0")
147
+    cnt = 0
148
+    for ev in qs.only("kgco2e"):
149
+        total_val += Decimal(ev.kgco2e)
150
+        cnt += 1
151
+    bal, _ = CarbonBalance.objects.get_or_create(organization=org, year=year, month=month)
152
+    bal.events_count = cnt
153
+    bal.approved_kgco2e = total_val.quantize(Decimal("0.000001"))
154
+    bal.save(update_fields=["events_count", "approved_kgco2e", "updated_at"])
155
+    return bal
156
+
157
+
158
+def get_totals_for_org(org: Organization) -> dict[str, Decimal]:
159
+    """Convenience totals for dashboards."""
160
+    from django.utils.timezone import now
161
+
162
+    today = now().date()
163
+    y = today.year
164
+    m = today.month
165
+    bal = CarbonBalance.objects.filter(organization=org, year=y, month=m).first()
166
+    pending = CarbonEvent.objects.filter(organization=org, status=CarbonEvent.STATUS_PENDING).count()
167
+    return {
168
+        "pending_events": pending,
169
+        "approved_mtd": (bal.approved_kgco2e if bal else Decimal("0")),
170
+    }

+ 17 - 0
recycle_core/signals.py

1
+from __future__ import annotations
2
+
3
+from django.db.models.signals import post_save
4
+from django.dispatch import receiver
5
+
6
+from .models import WeighLine
7
+from .services.carbon import create_event_for_line
8
+
9
+
10
+@receiver(post_save, sender=WeighLine)
11
+def weigh_line_saved(sender, instance: WeighLine, created: bool, **kwargs):
12
+    try:
13
+        create_event_for_line(instance)
14
+    except Exception:
15
+        # Fail silently to not block core operations
16
+        pass
17
+

+ 64 - 0
recycle_core/templates/recycle_core/carbon_events_list.html

1
+{% extends 'admin_frontend/base.html' %}
2
+{% load i18n humanize %}
3
+{% block title %}{% trans "Carbon Events" %}{% endblock %}
4
+{% block content %}
5
+<div class="max-w-6xl mx-auto">
6
+  <h1 class="text-2xl font-semibold mb-4">{% trans "Carbon Events" %}</h1>
7
+  <div class="bg-white rounded shadow p-4 mb-4">
8
+    <form method="get" class="grid md:grid-cols-4 gap-3">
9
+      {{ filter.form.organization }}
10
+      {{ filter.form.status }}
11
+      {{ filter.form.event_date }}
12
+      <div>
13
+        <button class="px-3 py-2 rounded bg-blue-600 text-white">{% trans "Filter" %}</button>
14
+      </div>
15
+    </form>
16
+  </div>
17
+  <form method="post" class="bg-white rounded shadow">
18
+    {% csrf_token %}
19
+    <div class="p-3 flex items-center gap-2 border-b">
20
+      <select name="action" class="border rounded px-2 py-1">
21
+        <option value="">-- {% trans "Bulk action" %} --</option>
22
+        <option value="approve">{% trans "Approve" %}</option>
23
+        <option value="reject">{% trans "Reject" %}</option>
24
+      </select>
25
+      <button class="px-3 py-1 rounded bg-gray-800 text-white">{% trans "Apply" %}</button>
26
+    </div>
27
+    <div class="overflow-x-auto">
28
+      <table class="min-w-full text-sm">
29
+        <thead>
30
+          <tr class="text-left text-gray-600">
31
+            <th class="px-3 py-2"><input type="checkbox" onclick="document.querySelectorAll('input[name=\'ids\']').forEach(cb=>cb.checked=this.checked)"></th>
32
+            <th class="px-3 py-2">{% trans "Date" %}</th>
33
+            <th class="px-3 py-2">{% trans "Organization" %}</th>
34
+            <th class="px-3 py-2">{% trans "Ticket" %}</th>
35
+            <th class="px-3 py-2">{% trans "Material" %}</th>
36
+            <th class="px-3 py-2 text-right">{% trans "Qty" %}</th>
37
+            <th class="px-3 py-2">{% trans "Unit" %}</th>
38
+            <th class="px-3 py-2 text-right">{% trans "kgCO2e" %}</th>
39
+            <th class="px-3 py-2">{% trans "Status" %}</th>
40
+          </tr>
41
+        </thead>
42
+        <tbody>
43
+          {% for ev in events %}
44
+          <tr class="border-t">
45
+            <td class="px-3 py-2"><input type="checkbox" name="ids" value="{{ ev.id }}"></td>
46
+            <td class="px-3 py-2 whitespace-nowrap">{{ ev.event_date|date:"Y-m-d H:i" }}</td>
47
+            <td class="px-3 py-2">{{ ev.organization.name }}</td>
48
+            <td class="px-3 py-2">{% if ev.weigh_ticket %}{{ ev.weigh_ticket.ticket_number|default:ev.weigh_ticket.id }}{% else %}-{% endif %}</td>
49
+            <td class="px-3 py-2">{{ ev.material|default:"-" }}</td>
50
+            <td class="px-3 py-2 text-right">{{ ev.quantity|floatformat:2|intcomma }}</td>
51
+            <td class="px-3 py-2">{{ ev.unit }}</td>
52
+            <td class="px-3 py-2 text-right">{{ ev.kgco2e|floatformat:2|intcomma }}</td>
53
+            <td class="px-3 py-2">{{ ev.get_status_display }}</td>
54
+          </tr>
55
+          {% empty %}
56
+          <tr><td colspan="9" class="px-3 py-6 text-center text-gray-600">{% trans "No events found." %}</td></tr>
57
+          {% endfor %}
58
+        </tbody>
59
+      </table>
60
+    </div>
61
+    {% include 'admin_frontend/_pagination.html' %}
62
+  </form>
63
+</div>
64
+{% endblock %}

+ 18 - 0
recycle_core/templates/recycle_core/emission_factor_form.html

1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs %}
3
+{% block title %}Edit Emission Factor{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+<div class="max-w-2xl mx-auto bg-white rounded shadow p-4">
7
+  <h1 class="text-xl font-semibold mb-3">Edit Emission Factor</h1>
8
+  <form method="post">
9
+    {% csrf_token %}
10
+    {% include 'admin_frontend/_form.html' with form=form submit_label='Save' %}
11
+  </form>
12
+  <div class="mt-3">
13
+    <a class="btn-outline" href="{{ cancel_url }}">Cancel</a>
14
+  </div>
15
+  
16
+</div>
17
+{% endblock %}
18
+

+ 72 - 0
recycle_core/templates/recycle_core/emission_factors_list.html

1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags breadcrumbs %}
3
+{% block title %}Emission Factors{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+<div class="flex items-center justify-between mb-4">
7
+  <h1 class="text-2xl font-semibold">Emission Factors</h1>
8
+  <details class="relative">
9
+    <summary class="cursor-pointer px-3 py-2 bg-blue-600 text-white rounded">New Factor</summary>
10
+    <div class="absolute right-0 mt-2 bg-white shadow rounded p-4 w-96">
11
+      {% include 'admin_frontend/_form.html' with form=form submit_label='Create' %}
12
+    </div>
13
+  </details>
14
+  
15
+</div>
16
+
17
+<div class="bg-white rounded shadow p-4 mb-4">
18
+  <form method="get" class="space-y-3 flex gap-2">
19
+    {{ filter.form|crispy }}
20
+    <div class="flex items-center gap-2">
21
+      <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
22
+      <a href="{% url 'recycle_core:emission_factors_list' %}" class="btn-outline">Reset</a>
23
+    </div>
24
+  </form>
25
+</div>
26
+
27
+<div class="bg-white rounded shadow">
28
+  <div class="p-4 border-b">
29
+    <h2 class="font-medium">Factors</h2>
30
+  </div>
31
+  <div class="overflow-x-auto">
32
+    <table class="min-w-full">
33
+      <thead>
34
+        <tr class="text-left text-sm text-gray-600">
35
+          <th class="px-4 py-2">Organization</th>
36
+          <th class="px-4 py-2">Material</th>
37
+          <th class="px-4 py-2">Unit</th>
38
+          <th class="px-4 py-2">kgCO2e/unit</th>
39
+          <th class="px-4 py-2">Source</th>
40
+          <th class="px-4 py-2">Effective</th>
41
+          <th class="px-4 py-2">Actions</th>
42
+        </tr>
43
+      </thead>
44
+      <tbody class="divide-y">
45
+        {% for f in factors %}
46
+        <tr>
47
+          <td class="px-4 py-2">{{ f.organization.name }}</td>
48
+          <td class="px-4 py-2">{{ f.material.name }}</td>
49
+          <td class="px-4 py-2">{{ f.unit }}</td>
50
+          <td class="px-4 py-2">{{ f.kgco2e_per_unit }}</td>
51
+          <td class="px-4 py-2">{{ f.source|default:'-' }}</td>
52
+          <td class="px-4 py-2">{{ f.effective_from|date:'Y-m-d' }}{% if f.effective_to %} → {{ f.effective_to|date:'Y-m-d' }}{% endif %}</td>
53
+          <td class="px-4 py-2 space-x-2">
54
+            <a class="text-blue-600 hover:underline" href="{% url 'recycle_core:emission_factor_edit' f.id %}">Edit</a>
55
+            <form method="post" action="{% url 'recycle_core:emission_factor_delete' f.id %}" class="inline">
56
+              {% csrf_token %}
57
+              <button class="text-red-600 hover:underline" onclick="return confirm('Delete factor?')">Delete</button>
58
+            </form>
59
+          </td>
60
+        </tr>
61
+        {% empty %}
62
+        <tr>
63
+          <td class="px-4 py-4 text-gray-500" colspan="7">No factors.</td>
64
+        </tr>
65
+        {% endfor %}
66
+      </tbody>
67
+    </table>
68
+  </div>
69
+  {% include 'admin_frontend/_pagination.html' %}
70
+</div>
71
+{% endblock %}
72
+

+ 116 - 0
recycle_core/templates/recycle_core/invoice_detail.html

1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs humanize %}
3
+{% block title %}Invoice #{{ invoice.id }}{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+
7
+<div class="flex items-center justify-between mb-4">
8
+  <div class="flex items-center gap-3">
9
+    {% if invoice.pickup_id %}
10
+      <a class="btn-outline" href="{% url 'recycle_core:pickup_detail' invoice.pickup_id %}">Back to Pickup</a>
11
+    {% endif %}
12
+    <h1 class="text-2xl font-semibold">Invoice #{{ invoice.id }}</h1>
13
+  </div>
14
+  <div class="text-sm text-gray-600">Status: <span class="px-2 py-1 rounded bg-gray-100">{{ invoice.status }}</span></div>
15
+</div>
16
+
17
+<div class="grid grid-cols-1 md:grid-cols-10 gap-6">
18
+  <div class="bg-white rounded shadow p-4 md:col-span-4">
19
+    <h2 class="font-medium mb-2">Details</h2>
20
+    <dl class="grid grid-cols-3 gap-2 text-sm">
21
+      <dt class="text-gray-500">Organization</dt><dd class="col-span-2">{{ invoice.organization.name }}</dd>
22
+      <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ invoice.customer.name }}</dd>
23
+      <dt class="text-gray-500">Issued</dt><dd class="col-span-2">{{ invoice.issued_at|default:"-" }}</dd>
24
+      <dt class="text-gray-500">Due</dt><dd class="col-span-2">{{ invoice.due_at|default:"-" }}</dd>
25
+      <dt class="text-gray-500">Currency</dt><dd class="col-span-2">{{ invoice.currency_code }}</dd>
26
+      <dt class="text-gray-500">Total</dt><dd class="col-span-2 text-right">{{ invoice.total_amount|floatformat:2|intcomma }} {{ invoice.currency_code }}</dd>
27
+      <dt class="text-gray-500">Paid</dt><dd class="col-span-2 text-right">{{ payments_total|floatformat:2|intcomma }} {{ invoice.currency_code }}</dd>
28
+      <dt class="text-gray-500">Balance</dt><dd class="col-span-2 text-right">{{ balance|floatformat:2|intcomma }} {{ invoice.currency_code }}</dd>
29
+    </dl>
30
+  </div>
31
+  <div class="bg-white rounded shadow p-4 md:col-span-6">
32
+    <h2 class="font-medium mb-2">Lines</h2>
33
+    <table class="min-w-full">
34
+      <thead>
35
+        <tr class="text-left text-sm text-gray-600">
36
+          <th class="px-2 py-1">Description</th>
37
+          <th class="px-2 py-1 text-right">Qty</th>
38
+          <th class="px-2 py-1 text-right">Unit</th>
39
+          <th class="px-2 py-1 text-right">Unit Price</th>
40
+          <th class="px-2 py-1 text-right">Line Total</th>
41
+        </tr>
42
+      </thead>
43
+      <tbody class="divide-y">
44
+        {% for line in invoice.lines.all %}
45
+        <tr>
46
+          <td class="px-2 py-1">{{ line.description|default:"-" }}</td>
47
+          <td class="px-2 py-1 text-right">{{ line.quantity|floatformat:3|intcomma }}</td>
48
+          <td class="px-2 py-1 text-right">{{ line.unit }}</td>
49
+          <td class="px-2 py-1 text-right">{{ line.unit_price|floatformat:2|intcomma }}</td>
50
+          <td class="px-2 py-1 text-right">{{ line.line_total|floatformat:2|intcomma }}</td>
51
+        </tr>
52
+        {% empty %}
53
+        <tr>
54
+          <td class="px-2 py-2 text-gray-500" colspan="5">No lines.</td>
55
+        </tr>
56
+        {% endfor %}
57
+      </tbody>
58
+    </table>
59
+  </div>
60
+</div>
61
+
62
+<div class="mt-6 grid grid-cols-1 md:grid-cols-10 gap-6">
63
+  <div class="bg-white rounded shadow p-4 md:col-span-6">
64
+    <h2 class="font-medium mb-2">Payments</h2>
65
+    <table class="min-w-full">
66
+      <thead>
67
+        <tr class="text-left text-sm text-gray-600">
68
+          <th class="px-2 py-1">Amount</th>
69
+          <th class="px-2 py-1">Received At</th>
70
+          <th class="px-2 py-1">Reference</th>
71
+        </tr>
72
+      </thead>
73
+      <tbody class="divide-y">
74
+        {% for p in invoice.payments.all %}
75
+        <tr>
76
+          <td class="px-2 py-1">{{ p.amount|floatformat:2|intcomma }} {{ invoice.currency_code }}</td>
77
+          <td class="px-2 py-1">{{ p.received_at|default:"-" }}</td>
78
+          <td class="px-2 py-1">{{ p.reference|default:"-" }}</td>
79
+        </tr>
80
+        {% empty %}
81
+        <tr>
82
+          <td class="px-2 py-2 text-gray-500" colspan="3">No payments recorded.</td>
83
+        </tr>
84
+        {% endfor %}
85
+      </tbody>
86
+    </table>
87
+  </div>
88
+  <div class="bg-white rounded shadow p-4 md:col-span-4">
89
+    <h2 class="font-medium mb-2">Add Payment</h2>
90
+    <form method="post" action="{% url 'billing:invoice_add_payment' invoice.id %}">
91
+      {% csrf_token %}
92
+      {% include 'admin_frontend/_form.html' with form=pay_form submit_label='Record Payment' %}
93
+    </form>
94
+  </div>
95
+  <div class="bg-white rounded shadow p-4 md:col-span-4">
96
+    <h2 class="font-medium mb-2">Update Status</h2>
97
+    <form method="post" action="{% url 'billing:invoice_set_status' invoice.id %}">
98
+      {% csrf_token %}
99
+      <div class="flex items-end gap-3">
100
+        <div>
101
+          <label class="block text-sm text-gray-600 mb-1">Status</label>
102
+          <select name="status" class="border rounded px-3 py-2">
103
+            {% for code,label in status_choices %}
104
+              <option value="{{ code }}" {% if invoice.status == code %}selected{% endif %}>{{ label }}</option>
105
+            {% endfor %}
106
+          </select>
107
+        </div>
108
+        <div>
109
+          <button class="px-3 py-2 rounded bg-gray-800 text-white">Update</button>
110
+        </div>
111
+      </div>
112
+    </form>
113
+  </div>
114
+</div>
115
+
116
+{% endblock %}

+ 2 - 1
recycle_core/templates/recycle_core/invoices_list.html

36
     <tbody class="divide-y divide-gray-200">
36
     <tbody class="divide-y divide-gray-200">
37
       {% for inv in invoices %}
37
       {% for inv in invoices %}
38
       <tr>
38
       <tr>
39
-        <td class="px-4 py-2">{{ inv.customer.name }}</td>
39
+        <td class="px-4 py-2"><a class="text-blue-700 hover:underline" href="{% url 'billing:invoice_detail' inv.id %}">{{ inv.customer.name }}</a></td>
40
         <td class="px-4 py-2">{{ inv.total_amount }} {{ inv.currency_code }}</td>
40
         <td class="px-4 py-2">{{ inv.total_amount }} {{ inv.currency_code }}</td>
41
         <td class="px-4 py-2">{{ inv.payments_total|default:0 }} {{ inv.currency_code }}</td>
41
         <td class="px-4 py-2">{{ inv.payments_total|default:0 }} {{ inv.currency_code }}</td>
42
         <td class="px-4 py-2">{{ inv.balance }} {{ inv.currency_code }}</td>
42
         <td class="px-4 py-2">{{ inv.balance }} {{ inv.currency_code }}</td>
56
                 </form>
56
                 </form>
57
               </div>
57
               </div>
58
             </details>
58
             </details>
59
+            <a class="btn-outline btn-xs" href="{% url 'billing:invoice_detail' inv.id %}">View</a>
59
           </div>
60
           </div>
60
         </td>
61
         </td>
61
       </tr>
62
       </tr>

+ 97 - 23
recycle_core/templates/recycle_core/pickup_detail.html

1
 {% extends "admin_frontend/base.html" %}
1
 {% extends "admin_frontend/base.html" %}
2
-{% load breadcrumbs %}
2
+{% load breadcrumbs humanize %}
3
 {% block title %}Pickup #{{ pickup.id }}{% endblock %}
3
 {% block title %}Pickup #{{ pickup.id }}{% endblock %}
4
 {% block content %}
4
 {% block content %}
5
 {% render_breadcrumbs breadcrumbs %}
5
 {% render_breadcrumbs breadcrumbs %}
7
 <div class="flex items-center justify-between mb-4">
7
 <div class="flex items-center justify-between mb-4">
8
   <h1 class="text-2xl font-semibold">Pickup #{{ pickup.id }}</h1>
8
   <h1 class="text-2xl font-semibold">Pickup #{{ pickup.id }}</h1>
9
   <div class="flex items-center gap-2">
9
   <div class="flex items-center gap-2">
10
-    {% if pickup.weigh_ticket %}
11
-      <a class="btn-outline" href="{% url 'recycle_core:weigh_ticket_detail' pickup.weigh_ticket.id %}">View Weigh Ticket</a>
12
-      {% if perms.recycle_core.generate_invoice %}
10
+    {% if perms.recycle_core.create_weigh_ticket %}
11
+      <a class="px-3 py-2 rounded bg-blue-600 text-white" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
12
+    {% endif %}
13
+    {% if perms.recycle_core.generate_invoice %}
13
       <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' pickup.id %}">
14
       <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' pickup.id %}">
14
         {% csrf_token %}
15
         {% csrf_token %}
15
         <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
16
         <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
16
       </form>
17
       </form>
17
-      {% endif %}
18
-    {% else %}
19
-      {% if perms.recycle_core.create_weigh_ticket %}
20
-      <a class="px-3 py-2 rounded bg-blue-600 text-white" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
21
-      {% endif %}
22
     {% endif %}
18
     {% endif %}
23
   </div>
19
   </div>
24
   
20
   
25
 </div>
21
 </div>
26
 
22
 
27
-<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
23
+<div class="grid grid-cols-1 gap-6">
28
   <div class="bg-white rounded shadow p-4">
24
   <div class="bg-white rounded shadow p-4">
29
     <h2 class="font-medium mb-2">Details</h2>
25
     <h2 class="font-medium mb-2">Details</h2>
30
     <dl class="grid grid-cols-3 gap-2 text-sm">
26
     <dl class="grid grid-cols-3 gap-2 text-sm">
60
         {% endfor %}
56
         {% endfor %}
61
       </tbody>
57
       </tbody>
62
     </table>
58
     </table>
59
+    <div class="mt-4 border-t pt-4">
60
+      <h3 class="font-medium mb-2">Add Item</h3>
61
+      <form method="post">
62
+        {% csrf_token %}
63
+        <input type="hidden" name="action" value="add_item">
64
+        <div class="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
65
+          <div>
66
+            <label class="block text-sm text-gray-600 mb-1">Material</label>
67
+            {{ add_item_form.material }}
68
+          </div>
69
+          <div>
70
+            <label class="block text-sm text-gray-600 mb-1">Est. Quantity</label>
71
+            {{ add_item_form.estimated_qty }}
72
+          </div>
73
+          <div>
74
+            <label class="block text-sm text-gray-600 mb-1">Unit</label>
75
+            {{ add_item_form.unit }}
76
+          </div>
77
+          <div>
78
+            <button class="px-3 py-2 rounded bg-blue-600 text-white">Add</button>
79
+          </div>
80
+        </div>
81
+      </form>
82
+    </div>
63
   </div>
83
   </div>
64
 </div>
84
 </div>
65
 
85
 
66
-<div class="mt-6 bg-white rounded shadow p-4">
86
+<div id="tickets" class="mt-6 bg-white rounded shadow p-4">
67
   <h2 class="font-medium mb-2">Weigh Tickets</h2>
87
   <h2 class="font-medium mb-2">Weigh Tickets</h2>
68
-  <table class="min-w-full">
88
+  <div class="overflow-x-auto">
89
+  <table class="min-w-[1100px]">
69
     <thead>
90
     <thead>
70
       <tr class="text-left text-sm text-gray-600">
91
       <tr class="text-left text-sm text-gray-600">
71
         <th class="px-2 py-1">Ticket</th>
92
         <th class="px-2 py-1">Ticket</th>
72
         <th class="px-2 py-1">Recorded</th>
93
         <th class="px-2 py-1">Recorded</th>
73
         <th class="px-2 py-1">Net</th>
94
         <th class="px-2 py-1">Net</th>
95
+        <th class="px-2 py-1 text-right">Unit Price</th>
96
+        <th class="px-2 py-1 text-right">Total</th>
74
         <th class="px-2 py-1">Actions</th>
97
         <th class="px-2 py-1">Actions</th>
75
       </tr>
98
       </tr>
76
     </thead>
99
     </thead>
77
     <tbody class="divide-y">
100
     <tbody class="divide-y">
78
-      {% if pickup.weigh_ticket %}
101
+      {% for t in pickup.weigh_tickets.all %}
79
         <tr>
102
         <tr>
80
-          <td class="px-2 py-1"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:weigh_ticket_detail' pickup.weigh_ticket.id %}">{{ pickup.weigh_ticket.ticket_number|default:pickup.weigh_ticket.id }}</a></td>
81
-          <td class="px-2 py-1">{{ pickup.weigh_ticket.recorded_at }}</td>
82
-          <td class="px-2 py-1">{{ pickup.weigh_ticket.net_weight }} {{ pickup.weigh_ticket.unit }}</td>
103
+          <td class="px-2 py-1"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:weigh_ticket_detail' t.id %}">{{ t.ticket_number|default:t.id }}</a></td>
104
+          <td class="px-2 py-1">{{ t.recorded_at }}</td>
105
+          <td class="px-2 py-1">{{ t.net_weight }} {{ t.unit }}</td>
106
+          <td class="px-2 py-1 text-right">{% if t.avg_unit %}{{ t.avg_unit|floatformat:2|intcomma }} {{ ticket_currency }}{% else %}-{% endif %}</td>
107
+          <td class="px-2 py-1 text-right">{% if t.total_value %}{{ t.total_value|floatformat:2|intcomma }} {{ ticket_currency }}{% else %}-{% endif %}</td>
83
           <td class="px-2 py-1">
108
           <td class="px-2 py-1">
84
             {% if perms.recycle_core.generate_invoice %}
109
             {% if perms.recycle_core.generate_invoice %}
85
-            <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' pickup.weigh_ticket.id %}">
110
+            <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' t.id %}">
86
               {% csrf_token %}
111
               {% csrf_token %}
87
               <button class="btn-outline btn-xs">Generate Invoice</button>
112
               <button class="btn-outline btn-xs">Generate Invoice</button>
88
             </form>
113
             </form>
89
             {% endif %}
114
             {% endif %}
90
           </td>
115
           </td>
91
         </tr>
116
         </tr>
92
-      {% else %}
117
+      {% empty %}
93
         <tr>
118
         <tr>
94
           <td colspan="4" class="px-2 py-2 text-gray-500">No weigh tickets yet.</td>
119
           <td colspan="4" class="px-2 py-2 text-gray-500">No weigh tickets yet.</td>
95
         </tr>
120
         </tr>
96
-      {% endif %}
121
+      {% endfor %}
97
     </tbody>
122
     </tbody>
98
   </table>
123
   </table>
124
+  </div>
99
 
125
 
100
-  {% if not pickup.weigh_ticket %}
101
-    <div class="mt-3">
126
+  <div class="mt-3">
127
+    {% if perms.recycle_core.create_weigh_ticket %}
102
       <a class="btn" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
128
       <a class="btn" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
103
-    </div>
104
-  {% endif %}
129
+    {% endif %}
130
+  </div>
131
+</div>
132
+
133
+<div class="mt-6 bg-white rounded shadow p-4">
134
+  <h2 class="font-medium mb-2">Invoices</h2>
135
+  <div class="overflow-x-auto">
136
+  <table class="min-w-[1100px]">
137
+    <thead>
138
+      <tr class="text-left text-sm text-gray-600">
139
+        <th class="px-2 py-1">Invoice</th>
140
+        <th class="px-2 py-1">Customer</th>
141
+        <th class="px-2 py-1 text-right">Total</th>
142
+        <th class="px-2 py-1 text-right">Paid</th>
143
+        <th class="px-2 py-1 text-right">Balance</th>
144
+        <th class="px-2 py-1">Status</th>
145
+        <th class="px-2 py-1">Issued</th>
146
+        <th class="px-2 py-1">Actions</th>
147
+      </tr>
148
+    </thead>
149
+    <tbody class="divide-y">
150
+      {% for inv in invoices %}
151
+      <tr>
152
+        <td class="px-2 py-1"><a class="text-blue-700 hover:underline" href="{% url 'billing:invoice_detail' inv.id %}">#{{ inv.id }}</a></td>
153
+        <td class="px-2 py-1">{{ inv.customer.name }}</td>
154
+        <td class="px-2 py-1 text-right">{{ inv.total_amount|floatformat:2|intcomma }} {{ inv.currency_code }}</td>
155
+        <td class="px-2 py-1 text-right">{{ inv.payments_total|default:0|floatformat:2|intcomma }} {{ inv.currency_code }}</td>
156
+        <td class="px-2 py-1 text-right">{{ inv.balance|floatformat:2|intcomma }} {{ inv.currency_code }}</td>
157
+        <td class="px-2 py-1"><span class="px-2 py-1 rounded bg-gray-100 text-gray-700">{{ inv.status }}</span></td>
158
+        <td class="px-2 py-1">{{ inv.issued_at|default:"-" }}</td>
159
+        <td class="px-2 py-1">
160
+          <div class="flex items-center gap-2">
161
+            <a class="btn-outline btn-xs" href="{% url 'billing:invoice_detail' inv.id %}">View</a>
162
+            {% if inv.status == 'draft' %}
163
+            <form method="post" action="{% url 'recycle_core:pickup_regenerate_invoice' pickup.id %}">
164
+              {% csrf_token %}
165
+              <button class="btn btn-xs" onclick="return confirm('Regenerate draft invoice from current tickets?')">Regenerate</button>
166
+            </form>
167
+            {% endif %}
168
+          </div>
169
+        </td>
170
+      </tr>
171
+      {% empty %}
172
+      <tr>
173
+        <td class="px-2 py-2 text-gray-500" colspan="8">No invoices for this pickup.</td>
174
+      </tr>
175
+      {% endfor %}
176
+    </tbody>
177
+  </table>
178
+  </div>
105
 </div>
179
 </div>
106
 
180
 
107
 {% endblock %}
181
 {% endblock %}

+ 14 - 12
recycle_core/templates/recycle_core/pickups_list.html

58
               <button class="btn btn-xs">Update</button>
58
               <button class="btn btn-xs">Update</button>
59
             </form>
59
             </form>
60
             {% endif %}
60
             {% endif %}
61
-            {% if p.weigh_ticket %}
62
-              {% if perms.recycle_core.generate_invoice %}
63
-              <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' p.id %}">
64
-                {% csrf_token %}
65
-                <button class="btn btn-xs">Generate Invoice</button>
66
-              </form>
67
-              {% endif %}
68
-              <a class="btn-outline btn-xs" href="{% url 'recycle_core:weigh_ticket_detail' p.weigh_ticket.id %}">View Ticket</a>
69
-            {% else %}
70
-              {% if perms.recycle_core.create_weigh_ticket %}
71
-              <a class="btn btn-xs" href="{% url 'recycle_core:pickup_create_weigh_ticket' p.id %}">Create Weigh Ticket</a>
72
-              {% endif %}
61
+            {% if perms.recycle_core.generate_invoice %}
62
+            <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' p.id %}">
63
+              {% csrf_token %}
64
+              <button class="btn btn-xs">Generate Invoice</button>
65
+            </form>
66
+            {% endif %}
67
+            {% if perms.recycle_core.create_weigh_ticket %}
68
+            <a class="btn btn-xs" href="{% url 'recycle_core:pickup_create_weigh_ticket' p.id %}">Create Weigh Ticket</a>
73
             {% endif %}
69
             {% endif %}
70
+            {% with count=p.weigh_tickets.count %}
71
+              {% if count %}
72
+                <span class="text-xs text-gray-600">{{ count }} ticket{{ count|pluralize }}</span>
73
+                <a class="btn-outline btn-xs" href="{% url 'recycle_core:pickup_detail' p.id %}#tickets">View</a>
74
+              {% endif %}
75
+            {% endwith %}
74
           </div>
76
           </div>
75
         </td>
77
         </td>
76
       </tr>
78
       </tr>

+ 6 - 0
recycle_core/templates/recycle_core/user_form.html

57
         {% endif %}
57
         {% endif %}
58
       </div>
58
       </div>
59
     {% endif %}
59
     {% endif %}
60
+    {% if form_extras %}
61
+    <div class="mt-4">
62
+      <h3 class="text-sm font-semibold mb-2">Contact & Address</h3>
63
+      {{ form_extras|crispy }}
64
+    </div>
65
+    {% endif %}
60
     {% if form_photo %}
66
     {% if form_photo %}
61
     <div class="mt-4">
67
     <div class="mt-4">
62
       <h3 class="text-sm font-semibold mb-2">Profile Photo</h3>
68
       <h3 class="text-sm font-semibold mb-2">Profile Photo</h3>

+ 149 - 11
recycle_core/templates/recycle_core/weigh_ticket_detail.html

1
 {% extends "admin_frontend/base.html" %}
1
 {% extends "admin_frontend/base.html" %}
2
-{% load breadcrumbs %}
2
+{% load breadcrumbs widget_tweaks humanize %}
3
 {% block title %}Weigh Ticket {{ ticket.ticket_number|default:ticket.id }}{% endblock %}
3
 {% block title %}Weigh Ticket {{ ticket.ticket_number|default:ticket.id }}{% endblock %}
4
 {% block content %}
4
 {% block content %}
5
 {% render_breadcrumbs breadcrumbs %}
5
 {% render_breadcrumbs breadcrumbs %}
13
       <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
13
       <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
14
     </form>
14
     </form>
15
     {% endif %}
15
     {% endif %}
16
+    <form method="post" action="{% url 'recycle_core:weigh_ticket_recalc_carbon' ticket.id %}">
17
+      {% csrf_token %}
18
+      <button class="px-3 py-2 rounded bg-gray-700 text-white">Recalculate Carbon</button>
19
+    </form>
16
   </div>
20
   </div>
17
 </div>
21
 </div>
18
 
22
 
19
-<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
20
-  <div class="bg-white rounded shadow p-4">
23
+<div class="grid grid-cols-1 md:grid-cols-10 gap-6 mb-6">
24
+  <div class="bg-white rounded shadow p-4 md:col-span-3">
21
     <h2 class="font-medium mb-2">Details</h2>
25
     <h2 class="font-medium mb-2">Details</h2>
22
     <dl class="grid grid-cols-3 gap-2 text-sm">
26
     <dl class="grid grid-cols-3 gap-2 text-sm">
23
       <dt class="text-gray-500">Pickup</dt><dd class="col-span-2"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:pickup_detail' ticket.pickup.id %}">#{{ ticket.pickup.id }}</a></dd>
27
       <dt class="text-gray-500">Pickup</dt><dd class="col-span-2"><a class="text-blue-700 hover:underline" href="{% url 'recycle_core:pickup_detail' ticket.pickup.id %}">#{{ ticket.pickup.id }}</a></dd>
24
       <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ ticket.pickup.customer.name }}</dd>
28
       <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ ticket.pickup.customer.name }}</dd>
25
       <dt class="text-gray-500">Recorded</dt><dd class="col-span-2">{{ ticket.recorded_at }}</dd>
29
       <dt class="text-gray-500">Recorded</dt><dd class="col-span-2">{{ ticket.recorded_at }}</dd>
26
-      <dt class="text-gray-500">Gross</dt><dd class="col-span-2">{{ ticket.gross_weight }} {{ ticket.unit }}</dd>
27
-      <dt class="text-gray-500">Tare</dt><dd class="col-span-2">{{ ticket.tare_weight }} {{ ticket.unit }}</dd>
28
-      <dt class="text-gray-500">Net</dt><dd class="col-span-2">{{ ticket.net_weight }} {{ ticket.unit }}</dd>
30
+      <dt class="text-gray-500">Gross</dt><dd class="col-span-2 text-right">{{ ticket.gross_weight|floatformat:3|intcomma }} {{ ticket.unit }}</dd>
31
+      <dt class="text-gray-500">Tare</dt><dd class="col-span-2 text-right">{{ ticket.tare_weight|floatformat:3|intcomma }} {{ ticket.unit }}</dd>
32
+      <dt class="text-gray-500">Net</dt><dd class="col-span-2 text-right">{{ ticket.net_weight|floatformat:3|intcomma }} {{ ticket.unit }}</dd>
33
+      <dt class="text-gray-500">Finalized</dt><dd class="col-span-2">{% if is_finalized %}Yes{% else %}No{% endif %}</dd>
29
     </dl>
34
     </dl>
35
+    {% if not is_finalized %}
36
+    <div class="mt-4 space-y-2">
37
+      {% if allow_adjust_net %}
38
+      <form method="post">
39
+        {% csrf_token %}
40
+        <input type="hidden" name="action" value="adjust_net_to_lines">
41
+        <button class="px-3 py-2 rounded bg-gray-800 text-white w-full">Adjust Net to Lines</button>
42
+      </form>
43
+      {% endif %}
44
+      {% if allow_residual %}
45
+      <form method="post">
46
+        {% csrf_token %}
47
+        <input type="hidden" name="action" value="add_residual_line">
48
+        <button class="px-3 py-2 rounded bg-gray-700 text-white w-full">Add Residual Line</button>
49
+      </form>
50
+      {% endif %}
51
+      <form method="post">
52
+        {% csrf_token %}
53
+        <input type="hidden" name="action" value="finalize_ticket">
54
+        <button class="px-3 py-2 rounded bg-blue-600 text-white w-full">Finalize Ticket</button>
55
+      </form>
56
+    </div>
57
+    {% endif %}
58
+    {% if perms.recycle_core.edit_weigh_ticket and not is_finalized %}
59
+      <div class="mt-6 border-t pt-4">
60
+        <button type="button" class="px-3 py-2 rounded bg-emerald-600 text-white w-full" onclick="document.getElementById('edit-weights-modal').classList.remove('hidden')">Edit Weights</button>
61
+      </div>
62
+      <!-- Modal -->
63
+      <div id="edit-weights-modal" class="hidden fixed inset-0 z-50">
64
+        <div class="absolute inset-0 bg-black/50" onclick="document.getElementById('edit-weights-modal').classList.add('hidden')"></div>
65
+        <div class="relative max-w-md w-full mx-auto mt-20 bg-white rounded shadow p-4">
66
+          <div class="flex items-center justify-between mb-2">
67
+            <h3 class="font-medium">Edit Weights</h3>
68
+            <button type="button" class="text-gray-600 hover:text-gray-800" onclick="document.getElementById('edit-weights-modal').classList.add('hidden')">✕</button>
69
+          </div>
70
+          <form method="post" class="grid grid-cols-1 gap-3">
71
+            {% csrf_token %}
72
+            <input type="hidden" name="action" value="edit_weights">
73
+            {% if weights_form.non_field_errors %}
74
+              <div class="text-red-700 text-sm">{{ weights_form.non_field_errors }}</div>
75
+            {% endif %}
76
+            <div>
77
+              <label class="block text-sm text-gray-600 mb-1">Gross</label>
78
+              {{ weights_form.gross_weight|attr:"class:w-full max-w-full text-right" }}
79
+              {% if weights_form.gross_weight.errors %}
80
+                <div class="text-red-700 text-xs mt-1">{{ weights_form.gross_weight.errors|striptags }}</div>
81
+              {% endif %}
82
+            </div>
83
+            <div>
84
+              <label class="block text-sm text-gray-600 mb-1">Tare</label>
85
+              {{ weights_form.tare_weight|attr:"class:w-full max-w-full text-right" }}
86
+              {% if weights_form.tare_weight.errors %}
87
+                <div class="text-red-700 text-xs mt-1">{{ weights_form.tare_weight.errors|striptags }}</div>
88
+              {% endif %}
89
+            </div>
90
+            <div>
91
+              <label class="block text-sm text-gray-600 mb-1">Unit</label>
92
+              {% if lines %}
93
+                <div title="Cannot change unit while lines exist">
94
+                  {{ weights_form.unit|attr:"class:w-full max-w-full"|attr:"disabled:disabled" }}
95
+                  <input type="hidden" name="unit" value="{{ weights_form.unit.value|default:ticket.unit }}" />
96
+                </div>
97
+              {% else %}
98
+                  {{ weights_form.unit|attr:"class:w-full max-w-full" }}
99
+              {% endif %}
100
+              {% if weights_form.unit.errors %}
101
+                <div class="text-red-700 text-xs mt-1">{{ weights_form.unit.errors|striptags }}</div>
102
+              {% endif %}
103
+            </div>
104
+            <div class="flex items-center justify-end gap-2 pt-1">
105
+              <button type="button" class="px-3 py-2 rounded border border-gray-300 text-gray-700" onclick="document.getElementById('edit-weights-modal').classList.add('hidden')">Cancel</button>
106
+              <button class="px-3 py-2 rounded bg-emerald-600 text-white">Save</button>
107
+            </div>
108
+          </form>
109
+        </div>
110
+      </div>
111
+    {% endif %}
30
   </div>
112
   </div>
31
-  <div class="bg-white rounded shadow p-4">
113
+  <div class="bg-white rounded shadow p-4 md:col-span-7">
32
     <h2 class="font-medium mb-2">Lines</h2>
114
     <h2 class="font-medium mb-2">Lines</h2>
115
+    <div class="text-sm mb-3">
116
+      <div>
117
+        <span class="text-gray-600">Net Weight:</span>
118
+        <span class="font-medium">{{ ticket.net_weight }} {{ ticket.unit }}</span>
119
+      </div>
120
+      <div>
121
+        <span class="text-gray-600">Sum of Lines ({{ ticket.unit }}):</span>
122
+        <span class="font-medium">{{ lines_sum }} {{ ticket.unit }}</span>
123
+        {% if has_unconvertible %}
124
+          <span class="ml-2 text-xs text-amber-700">Includes unconverted items (different units)</span>
125
+        {% endif %}
126
+      </div>
127
+      <div>
128
+        <span class="text-gray-600">Difference:</span>
129
+        <span class="font-medium {% if lines_delta != 0 %}text-red-700{% endif %}">{{ lines_delta }} {{ ticket.unit }}</span>
130
+      </div>
131
+    </div>
33
     <table class="min-w-full">
132
     <table class="min-w-full">
34
       <thead>
133
       <thead>
35
         <tr class="text-left text-sm text-gray-600">
134
         <tr class="text-left text-sm text-gray-600">
36
           <th class="px-2 py-1">Material</th>
135
           <th class="px-2 py-1">Material</th>
37
-          <th class="px-2 py-1">Qty</th>
136
+          <th class="px-2 py-1 text-right">Qty</th>
38
           <th class="px-2 py-1">Unit</th>
137
           <th class="px-2 py-1">Unit</th>
138
+          <th class="px-2 py-1">Actions</th>
39
         </tr>
139
         </tr>
40
       </thead>
140
       </thead>
41
       <tbody class="divide-y">
141
       <tbody class="divide-y">
42
         {% for line in lines %}
142
         {% for line in lines %}
43
           <tr>
143
           <tr>
44
-            <td class="px-2 py-1">{{ line.material.name }}</td>
45
-            <td class="px-2 py-1">{{ line.quantity }}</td>
46
-            <td class="px-2 py-1">{{ line.unit }}</td>
144
+            <form method="post">
145
+              {% csrf_token %}
146
+              <input type="hidden" name="action" value="edit">
147
+              <input type="hidden" name="line_id" value="{{ line.id }}">
148
+              <td class="px-2 py-1">{{ line.form.material|attr:"class:w-full max-w-full" }}</td>
149
+              <td class="px-2 py-1 text-right">{{ line.form.quantity|attr:"class:w-full max-w-full text-right" }}</td>
150
+              <td class="px-2 py-1">{{ line.form.unit|attr:"class:w-full max-w-full" }}</td>
151
+              <td class="px-2 py-1">
152
+                <button class="px-2 py-1 rounded bg-blue-600 text-white text-sm">Save</button>
153
+                <button form="del-line-{{ line.id }}" class="px-2 py-1 rounded bg-red-600 text-white text-sm" onclick="return confirm('Delete this line?')">Delete</button>
154
+              </td>
155
+            </form>
156
+            <form id="del-line-{{ line.id }}" method="post" class="hidden">
157
+              {% csrf_token %}
158
+              <input type="hidden" name="action" value="delete">
159
+              <input type="hidden" name="line_id" value="{{ line.id }}">
160
+            </form>
47
           </tr>
161
           </tr>
48
         {% empty %}
162
         {% empty %}
49
           <tr>
163
           <tr>
52
         {% endfor %}
166
         {% endfor %}
53
       </tbody>
167
       </tbody>
54
     </table>
168
     </table>
169
+    <div class="mt-4 border-t pt-4">
170
+      <h3 class="font-medium mb-2">Add Line</h3>
171
+      <form method="post">
172
+        {% csrf_token %}
173
+        <input type="hidden" name="action" value="add">
174
+        <div class="grid grid-cols-1 md:grid-cols-4 gap-4 md:gap-6 items-end">
175
+          <div>
176
+            <label class="block text-sm text-gray-600 mb-1">Material</label>
177
+            {{ add_line_form.material|attr:"class:w-full max-w-full" }}
178
+          </div>
179
+          <div>
180
+            <label class="block text-sm text-gray-600 mb-1">Quantity</label>
181
+            {{ add_line_form.quantity|attr:"class:w-full max-w-full text-right" }}
182
+          </div>
183
+          <div>
184
+            <label class="block text-sm text-gray-600 mb-1">Unit</label>
185
+            {{ add_line_form.unit|attr:"class:w-full max-w-full" }}
186
+          </div>
187
+          <div>
188
+            <button class="px-3 py-2 rounded bg-blue-600 text-white">Add</button>
189
+          </div>
190
+        </div>
191
+      </form>
192
+    </div>
55
   </div>
193
   </div>
56
 </div>
194
 </div>
57
 
195
 

+ 10 - 0
recycle_core/urls.py

22
     path("pickups/<int:pk>/assign/", views.pickup_assign, name="pickup_assign"),
22
     path("pickups/<int:pk>/assign/", views.pickup_assign, name="pickup_assign"),
23
     path("pickups/<int:pk>/set-status/", views.pickup_set_status, name="pickup_set_status"),
23
     path("pickups/<int:pk>/set-status/", views.pickup_set_status, name="pickup_set_status"),
24
     path("pickups/<int:pk>/generate-invoice/", views.pickup_generate_invoice, name="pickup_generate_invoice"),
24
     path("pickups/<int:pk>/generate-invoice/", views.pickup_generate_invoice, name="pickup_generate_invoice"),
25
+    path("pickups/<int:pk>/regenerate-invoice/", views.pickup_regenerate_invoice, name="pickup_regenerate_invoice"),
25
     path("pickups/<int:pk>/create-weigh-ticket/", views.pickup_create_weigh_ticket, name="pickup_create_weigh_ticket"),
26
     path("pickups/<int:pk>/create-weigh-ticket/", views.pickup_create_weigh_ticket, name="pickup_create_weigh_ticket"),
26
     path("pickups/<int:pk>/", views.pickup_detail, name="pickup_detail"),
27
     path("pickups/<int:pk>/", views.pickup_detail, name="pickup_detail"),
27
 
28
 
33
     path("weigh-tickets/", views.weigh_tickets_list, name="weigh_tickets_list"),
34
     path("weigh-tickets/", views.weigh_tickets_list, name="weigh_tickets_list"),
34
     path("weigh-tickets/<int:pk>/", views.weigh_ticket_detail, name="weigh_ticket_detail"),
35
     path("weigh-tickets/<int:pk>/", views.weigh_ticket_detail, name="weigh_ticket_detail"),
35
     path("weigh-tickets/<int:pk>/generate-invoice/", views.weigh_ticket_generate_invoice, name="weigh_ticket_generate_invoice"),
36
     path("weigh-tickets/<int:pk>/generate-invoice/", views.weigh_ticket_generate_invoice, name="weigh_ticket_generate_invoice"),
37
+    path("weigh-tickets/<int:pk>/recalc-carbon/", views.weigh_ticket_recalc_carbon, name="weigh_ticket_recalc_carbon"),
36
 
38
 
37
 
39
 
38
     path("documents/", views.documents_list, name="documents_list"),
40
     path("documents/", views.documents_list, name="documents_list"),
45
     path("services/<int:pk>/delete/", views.service_delete, name="service_delete"),
47
     path("services/<int:pk>/delete/", views.service_delete, name="service_delete"),
46
     path("services/<int:pk>/toggle-enabled/", views.service_toggle_enabled, name="service_toggle_enabled"),
48
     path("services/<int:pk>/toggle-enabled/", views.service_toggle_enabled, name="service_toggle_enabled"),
47
     path("services/reorder/", views.services_reorder, name="services_reorder"),
49
     path("services/reorder/", views.services_reorder, name="services_reorder"),
50
+
51
+    # Carbon approvals
52
+    path("carbon-events/", views.carbon_events_list, name="carbon_events_list"),
53
+
54
+    # Emission factors
55
+    path("emission-factors/", views.emission_factors_list, name="emission_factors_list"),
56
+    path("emission-factors/<int:pk>/edit/", views.emission_factor_edit, name="emission_factor_edit"),
57
+    path("emission-factors/<int:pk>/delete/", views.emission_factor_delete, name="emission_factor_delete"),
48
 ]
58
 ]

+ 531 - 14
recycle_core/views.py

20
     ProvidedService,
20
     ProvidedService,
21
     Customer,
21
     Customer,
22
     CustomerSite,
22
     CustomerSite,
23
+    PickupItem,
24
+    PriceList,
25
+    PriceListItem,
23
     PickupOrder,
26
     PickupOrder,
24
     WeighTicket,
27
     WeighTicket,
25
     WeighLine,
28
     WeighLine,
27
     ScrapBid,
30
     ScrapBid,
28
     Document,
31
     Document,
29
     AuditLog,
32
     AuditLog,
33
+    CarbonEvent,
34
+    CarbonBalance,
35
+    MaterialEmissionFactor,
30
 )
36
 )
31
 from .forms import (
37
 from .forms import (
32
     MaterialForm,
38
     MaterialForm,
37
     DocumentForm,
43
     DocumentForm,
38
     ProvidedServiceForm,
44
     ProvidedServiceForm,
39
     WeighTicketForm,
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
 from django.contrib.contenttypes.models import ContentType
54
 from django.contrib.contenttypes.models import ContentType
43
 from django.contrib.auth import get_user_model
55
 from django.contrib.auth import get_user_model
44
 from django.http import HttpResponse, JsonResponse
56
 from django.http import HttpResponse, JsonResponse
226
         "is_active": user.is_active,
238
         "is_active": user.is_active,
227
     })
239
     })
228
     form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
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
     if request.method == "POST":
256
     if request.method == "POST":
231
         ok_main = form.is_valid()
257
         ok_main = form.is_valid()
232
         ok_photo = True if form_photo is None else form_photo.is_valid()
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
             user.email = form.cleaned_data.get("email") or ""
261
             user.email = form.cleaned_data.get("email") or ""
235
             user.first_name = form.cleaned_data.get("first_name") or ""
262
             user.first_name = form.cleaned_data.get("first_name") or ""
236
             user.last_name = form.cleaned_data.get("last_name") or ""
263
             user.last_name = form.cleaned_data.get("last_name") or ""
252
             if form_photo is not None:
279
             if form_photo is not None:
253
                 form_photo.instance = profile
280
                 form_photo.instance = profile
254
                 form_photo.save()
281
                 form_photo.save()
282
+            if form_extras is not None:
283
+                form_extras.instance = profile
284
+                form_extras.save()
255
             messages.success(request, f"User '{user.username}' updated.")
285
             messages.success(request, f"User '{user.username}' updated.")
256
             next_url = request.POST.get("next") or request.GET.get("next")
286
             next_url = request.POST.get("next") or request.GET.get("next")
257
             if next_url:
287
             if next_url:
258
                 return redirect(next_url)
288
                 return redirect(next_url)
259
             return redirect("recycle_core:org_users_list")
289
             return redirect("recycle_core:org_users_list")
260
         messages.error(request, "Please correct the errors below.")
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
 @owner_required
294
 @owner_required
534
         PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
563
         PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
535
     )
564
     )
536
     items = pickup.items.select_related("material").all()
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
     context = {
629
     context = {
538
         "nav": _nav_items(),
630
         "nav": _nav_items(),
539
         "pickup": pickup,
631
         "pickup": pickup,
540
         "items": items,
632
         "items": items,
633
+        "add_item_form": add_item_form,
634
+        "invoices": list(inv_qs),
635
+        "ticket_currency": currency_code,
541
     }
636
     }
542
     return render(request, "recycle_core/pickup_detail.html", context)
637
     return render(request, "recycle_core/pickup_detail.html", context)
543
 
638
 
546
 @permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
641
 @permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
547
 def pickup_create_weigh_ticket(request, pk: int):
642
 def pickup_create_weigh_ticket(request, pk: int):
548
     pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
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
     if request.method == "POST":
645
     if request.method == "POST":
554
         form = WeighTicketForm(request.POST)
646
         form = WeighTicketForm(request.POST)
584
 def pickup_generate_invoice(request, pk: int):
676
 def pickup_generate_invoice(request, pk: int):
585
     pickup = get_object_or_404(PickupOrder, pk=pk)
677
     pickup = get_object_or_404(PickupOrder, pk=pk)
586
     try:
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
         invoice = generate_invoice_for_pickup(pickup)
682
         invoice = generate_invoice_for_pickup(pickup)
591
         messages.success(request, f"Invoice #{invoice.id} generated.")
683
         messages.success(request, f"Invoice #{invoice.id} generated.")
592
     except Exception as e:
684
     except Exception as e:
593
         messages.error(request, f"Failed to generate invoice: {e}")
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
 # Scrap Listings UI ----------------------------------------------------------
702
 # Scrap Listings UI ----------------------------------------------------------
738
 @staff_member_required
843
 @staff_member_required
739
 @breadcrumbs(label="Ticket Detail", parent="re_tickets")
844
 @breadcrumbs(label="Ticket Detail", parent="re_tickets")
740
 def weigh_ticket_detail(request, pk: int):
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
     lines = WeighLine.objects.select_related("material").filter(ticket=ticket)
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
     ct = ContentType.objects.get_for_model(WeighTicket)
1028
     ct = ContentType.objects.get_for_model(WeighTicket)
744
     docs = Document.objects.filter(content_type=ct, object_id=ticket.id)
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
     context = {
1075
     context = {
746
         "nav": _nav_items(),
1076
         "nav": _nav_items(),
747
         "ticket": ticket,
1077
         "ticket": ticket,
748
         "lines": lines,
1078
         "lines": lines,
749
         "generate_url": reverse("recycle_core:weigh_ticket_generate_invoice", args=[ticket.id]),
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
         "documents": docs,
1082
         "documents": docs,
751
         "ct_id": ct.id,
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
     return render(request, "recycle_core/weigh_ticket_detail.html", context)
1091
     return render(request, "recycle_core/weigh_ticket_detail.html", context)
754
 
1092
 
763
         messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
1101
         messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
764
     except Exception as e:
1102
     except Exception as e:
765
         messages.error(request, f"Failed to generate invoice: {e}")
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
 # Billing-related views moved to billing/views.py
1119
 # Billing-related views moved to billing/views.py
927
             log.metadata,
1277
             log.metadata,
928
         ])
1278
         ])
929
     return response
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
 @staff_member_required
1447
 @staff_member_required
931
 @public_route(label="Provided Services", order=15, icon="sparkles")
1448
 @public_route(label="Provided Services", order=15, icon="sparkles")
932
 @breadcrumbs(label="Provided Services", name="rc_services")
1449
 @breadcrumbs(label="Provided Services", name="rc_services")