added print, download as pdf. no puppeteer, we'll see

This commit is contained in:
2026-09-22 22:06:06 +03:00
parent 122480cd46
commit 6f614270e7
37 changed files with 3955 additions and 473 deletions
@@ -0,0 +1,2 @@
- [SSE operation_map fallback](sse-operation-map-fallback.md) — unmapped `sse_broadcast_update` action strings pass through to the frontend verbatim as `operation`.
- [NomenclatureContext SSE dispatch structure](nomenclature-context-sse-dispatch.md) — how `handleSSEUpdate` routes object type to array key and operation to array mutation.
@@ -0,0 +1,33 @@
---
name: nomenclature-context-sse-dispatch
description: How handleSSEUpdate in NomenclatureContext.jsx routes an SSE message's object/operation to state mutations
metadata:
type: project
---
`frontend/src/contexts/NomenclatureContext.jsx`'s `handleSSEUpdate(message)`:
1. Special-cases `object === 'nomenclature' | 'nomenclature_entry' | 'extra_combination'`
and `object === 'invoice' | 'notice'` — these just fan out to `subscribersRef`
callbacks and return early, bypassing the nomenclature-array logic entirely.
2. Otherwise maps `message.object` (the SSE `sseObjectType`, e.g. `'vehicle'`) to a
`nomenclatureKey` (e.g. `'vehicles'`) via `getNomenclatureKeyFromSSE`, which just
looks up `NOMENCLATURES_CONFIG` by `sseObjectType` — this mapping is
operation-agnostic, so it already works correctly for any new operation string
on an existing object type (no changes needed there when adding new operations).
3. Inside `setNomenclatures(prev => {...})`, an `if/else if` chain on `operation`
decides how to mutate `prev[nomenclatureKey]`:
- `'insert'`: skip if `id` already present, else `insertSorted`.
- `'update'`: `updateSorted` (removes old, re-inserts at sorted position, or
falls back to `insertSorted` if the item wasn't present at all).
- Falls through to `return prev` (no-op) for anything unhandled — this was true
even for the backend's own long-standing `'deleted'` action before this
session; there was no delete/removal handling of any kind until Phase B of
`.claude/plans/finalize-history-void.md` added `'archived'` (filter out by id)
and `'voided'` (update-in-place if present, safe no-op if not) as new
`else if` branches alongside `insert`/`update`.
`insertSorted`/`updateSorted` (same file, ~line 62 and ~90) are the two shared
mutation helpers — always spread-based (`[...array]`, `.filter`, `.map`), never
in-place `.push`/`.splice` on the original array. Any new operation case should
reuse these two helpers rather than writing new sort/insert logic.
@@ -0,0 +1,37 @@
---
name: sse-operation-map-fallback
description: sse_broadcast_update's operation_map only maps created/updated/deleted; other action strings pass through unchanged to the frontend's `operation` field
metadata:
type: project
---
`backend/scalesapp/sse.py`'s `sse_broadcast_update(object_name, action, data)` maps
`action` through a small dict:
```python
operation_map = {
'created': 'insert',
'updated': 'update',
'deleted': 'delete'
}
...
'operation': operation_map.get(action, action),
```
Any action string NOT in that dict (e.g. `'archived'`, `'voided'`, added for the
vehicle finalize/void feature) is **not translated** — it arrives client-side in
`message.operation` exactly as the backend passed it, verbatim.
**Why:** confirmed by reading the actual source during Phase B of
`.claude/plans/finalize-history-void.md` (finalize/history/void feature), rather
than assuming the plan's own hedge ("mapped through operation_map, or handled as
raw fallback — check which is simpler") was already resolved. It wasn't resolved
in the plan text; the code settles it.
**How to apply:** when adding a new `handleSSEUpdate` case in
`frontend/src/contexts/NomenclatureContext.jsx` for a new backend action, do NOT
assume the operation string will be renamed/normalized — check `operation_map` in
`backend/scalesapp/sse.py` first, and if the action isn't in the map, write the
frontend case using the exact same string the backend passes as `action`. Do not
add entries to `operation_map` yourself for a frontend-only task — that's a
backend change and out of scope unless explicitly asked.
+11 -3
View File
@@ -159,22 +159,25 @@ class VehicleSerializer(serializers.ModelSerializer):
tare_user_name = serializers.CharField(source='tare_user.username', read_only=True, default=None)
gross_user_name = serializers.CharField(source='gross_user.username', read_only=True, default=None)
net_user_name = serializers.CharField(source='net_user.username', read_only=True, default=None)
void_user_name = serializers.CharField(source='void_user.username', read_only=True, default=None)
class Meta:
model = Vehicle
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid',
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name',
'tare', 'tare_date', 'tare_user', 'tare_user_name',
'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note',
'gross', 'gross_date', 'gross_user', 'gross_user_name',
'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
'net', 'net_date', 'net_user', 'net_user_name',
'doc_number', 'extra']
'doc_number', 'extra',
'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason']
read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name',
'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note',
'gross_date', 'gross_user', 'gross_user_name',
'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
'net', 'net_date', 'net_user', 'net_user_name',
'doc_number']
'doc_number',
'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason']
def create(self, validated_data):
extra_data = validated_data.pop('extra', None)
@@ -208,6 +211,11 @@ class VehicleSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("Vehicle number cannot be empty")
return value.strip()
def validate(self, attrs):
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs
class ExtraCombinationSerializer(serializers.ModelSerializer):
class Meta:
+65 -6
View File
@@ -228,14 +228,19 @@ class VehicleViewSet(viewsets.ModelViewSet):
retrieve: Get a specific vehicle by ID
update: Full update of vehicle and extra data
partial_update: Partial update of vehicle
destroy: Delete a vehicle (cascades to VehicleExtra)
destroy: Disabled - vehicles are never deleted, see void() instead
by_number: Get vehicle by vehicle_number
measure: Record a weighing (app assigns tare vs gross by weight)
void: Flag a completed vehicle as voided (corrective reversal, not a delete)
mark_notice_printed: Signal that a completed vehicle's notice was printed/downloaded
"""
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['vehicle_number']
filterset_fields = {
'vehicle_number': ['exact'],
'doc_number': ['isnull'],
}
search_fields = ['vehicle_number']
ordering = ['vehicle_number']
@@ -251,10 +256,17 @@ class VehicleViewSet(viewsets.ModelViewSet):
sse_broadcast_update('vehicle', 'updated', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_destroy(self, instance):
data = VehicleSerializer(instance).data
instance.delete()
sse_broadcast_update('vehicle', 'deleted', data)
def destroy(self, request, *args, **kwargs):
# Vehicles are never deleted - a completed weighing is corrected with
# /void/ instead, which keeps the record (and its doc_number) intact
# and visible in History. Overriding destroy(), not perform_destroy():
# ModelViewSet.destroy() calls perform_destroy() then always returns
# 204, so neutering only perform_destroy would silently no-op instead
# of telling the caller this action doesn't exist.
return Response(
{'detail': 'Vehicles cannot be deleted. Use /void/ on a completed vehicle instead.'},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@action(detail=False, methods=['get'], url_path='by-number')
def by_number(self, request):
@@ -321,6 +333,53 @@ class VehicleViewSet(viewsets.ModelViewSet):
sse_broadcast_update('vehicle', 'updated', serializer.data)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='void')
def void(self, request, pk=None):
"""Flag a completed vehicle as voided. Does not touch doc_number/net/tare/gross."""
if not request.user.can_edit_documents:
return Response({'detail': 'You do not have permission to void a document.'},
status=status.HTTP_403_FORBIDDEN)
vehicle = self.get_object()
if not vehicle.doc_number:
return Response({'detail': 'Only a completed vehicle can be voided.'},
status=status.HTTP_400_BAD_REQUEST)
reason = (request.data.get('reason') or '').strip()
if not reason:
return Response({'detail': 'A reason is required to void a document.'},
status=status.HTTP_400_BAD_REQUEST)
vehicle.is_void = True
vehicle.void_date = timezone.now()
vehicle.void_user = request.user
vehicle.void_reason = reason
vehicle.save()
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'voided', serializer.data)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='mark-notice-printed')
def mark_notice_printed(self, request, pk=None):
"""Signal that the operator has acted on a completed vehicle's notice (print/download).
Makes no DB change - doc_number already marks the vehicle as finalized.
This only broadcasts the SSE event that removes it from the active list.
"""
if not request.user.can_measure:
return Response({'detail': 'You do not have permission to do this.'},
status=status.HTTP_403_FORBIDDEN)
vehicle = self.get_object()
if not vehicle.doc_number:
return Response({'detail': 'Only a completed vehicle can be archived.'},
status=status.HTTP_400_BAD_REQUEST)
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'archived', serializer.data)
return Response(serializer.data)
class ExtraCombinationViewSet(mixins.ListModelMixin,
mixins.RetrieveModelMixin,
@@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-09-21 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"vehicles",
"0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more",
),
]
operations = [
migrations.AddField(
model_name="vehicle",
name="driver_name",
field=models.CharField(blank=True, max_length=50, null=True),
),
]
@@ -0,0 +1,55 @@
# Generated by Django 6.0.1 on 2026-09-22 17:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("vehicles", "0007_vehicle_driver_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="vehicle",
name="is_void",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="vehicle",
name="void_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="vehicle",
name="void_reason",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="vehicle",
name="void_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_vehicle_void",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="vehicle",
name="vehicle_number",
field=models.CharField(max_length=15),
),
migrations.AddConstraint(
model_name="vehicle",
constraint=models.UniqueConstraint(
condition=models.Q(("doc_number__isnull", True)),
fields=("vehicle_number",),
name="unique_active_vehicle_number",
),
),
]
+15 -2
View File
@@ -8,11 +8,12 @@ from api.models import User
# Create your models here.
class Vehicle(models.Model):
vehicle_number = models.CharField(max_length=15, unique=True)
vehicle_number = models.CharField(max_length=15)
trailer1_number = models.CharField(max_length=15, null=True, blank=True)
trailer2_number = models.CharField(max_length=15, null=True, blank=True)
driver_pid = models.CharField(max_length=50, null=True, blank=True)
driver_name = models.CharField(max_length=50, null=True, blank=True)
tare = models.IntegerField(null=True, blank=True)
tare_date = models.DateTimeField(null=True, blank=True)
tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL)
@@ -35,7 +36,19 @@ class Vehicle(models.Model):
doc_number = models.CharField(max_length=20, null=True, blank=True, unique=True)
is_void = models.BooleanField(default=False)
void_date = models.DateTimeField(null=True, blank=True)
void_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_void', on_delete=models.SET_NULL)
void_reason = models.CharField(max_length=255, null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['vehicle_number'],
condition=models.Q(doc_number__isnull=True),
name='unique_active_vehicle_number',
),
]
def __str__(self):
return f"{self.vehicle_number}"
+7 -1
View File
@@ -4,4 +4,10 @@ from .models import Vehicle
class VehicleSerializer(serializers.ModelSerializer):
class Meta:
model = Vehicle
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user']
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user']
def validate(self, attrs):
"""Require driver_name on creation only; existing vehicles may have it as NULL."""
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs