added print, download as pdf. no puppeteer, we'll see
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user