Plans record the decisions behind each feature and, for the protocol and licensing work, what remains unvalidated against real hardware. ~WRL0003.tmp was a Word autosave artifact committed by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
Quick-Fill Combinations for Vehicle Additional Data
Context
Trucks frequently arrive one after another carrying the same cargo, from the same speditor, via the same agent. Today the operator re-selects every dropdown in the "Additional Data" section of the vehicle edit form for each truck, even when nothing changed from the previous one.
This adds a helper table above those fields listing previously-used combinations of the lookup values. One click fills all the dropdowns. Rows are created automatically when a vehicle's extra data is saved, and the user deletes rows they no longer need.
Decisions made with the user:
- A "combination" = only
kind='lookup'nomenclature values. Free-text/number/bool fields are excluded so one-off notes don't pollute the table. - Re-using an existing combination bumps its
last_usedand incrementsuse_count. No duplicate rows. - Rows are shared by all users (global, not per-user).
- Rows persist indefinitely — no cap, no auto-trim. Manual delete only.
- Table renders inline, always visible, directly above the existing fields, in both the new-vehicle and edit forms.
Backend
1. Model — backend/vehicles/models.py
Lives in vehicles/ because it's a usage history of VehicleExtra.data, a domain model. (api/ holds the API layer; vehicles/views.py|serializers.py|urls.py are dead, broken, unrouted code — do not touch them.)
Dedup uses a canonical hash, not JSONField equality: on SQLite a JSONField exact-match compares the serialized string, so {"A":1,"B":2} ≠ {"B":2,"A":1}. A unique=True SHA-256 column is backend-agnostic and O(1).
Add import hashlib / import json at the top, then:
def combination_hash(data):
"""Order-independent canonical hash of a {nomenclature_code: entry_id} dict."""
canonical = json.dumps(data, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
class ExtraCombination(models.Model):
"""A previously-used combination of lookup nomenclature values for a vehicle's extra data."""
data = models.JSONField(default=dict)
data_hash = models.CharField(max_length=64, unique=True, editable=False)
use_count = models.IntegerField(default=1)
last_used = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-last_used"]
def save(self, *args, **kwargs):
self.data_hash = combination_hash(self.data)
super().save(*args, **kwargs)
def __str__(self):
return f"{self.data} (x{self.use_count})"
Computing the hash inside save() means it can never drift from data. auto_now_add (not auto_now) + an explicit bump in the recorder keeps the timestamp meaning "last used", not "last saved".
Migration: python manage.py makemigrations vehicles → 0005_extracombination.py (latest is currently 0004_).
2. Serializer — backend/api/serializers.py
Extract the existing validation at api/serializers.py:85-123 into a module-level function with a lookup_only flag, so both serializers share it. Behaviour for the existing path is bit-identical.
def validate_vehicle_extra_data(value, lookup_only=False):
"""Validate a {nomenclature_code: value} dict against vehicle nomenclatures.
lookup_only=True rejects any key whose nomenclature is not kind='lookup'.
"""
Body is the current validate_data verbatim, with one insert in the Nomenclature.FIELD branch:
elif nom.kind == Nomenclature.FIELD:
if lookup_only:
errors[key] = "Only lookup nomenclatures are allowed here."
continue
field_def = nom.fields.first()
# ...rest unchanged
VehicleExtraSerializer.validate_data becomes return validate_vehicle_extra_data(value).
New serializer (after VehicleSerializer):
class ExtraCombinationSerializer(serializers.ModelSerializer):
class Meta:
model = ExtraCombination
fields = ['id', 'data', 'use_count', 'last_used']
read_only_fields = ['id', 'use_count', 'last_used']
def validate_data(self, value):
if not value:
raise serializers.ValidationError("Combination cannot be empty.")
return validate_vehicle_extra_data(value, lookup_only=True)
Returns raw {code: entry_id} — no denormalized labels. The frontend already has getDisplayValue(code, entryId) backed by an SSE-live cache, so labels update in realtime when an entry is renamed. Server-side labels would be a stale snapshot requiring re-broadcast of every row on every entry edit.
3. Recorder + ViewSet — backend/api/views.py
Imports: add mixins to the DRF import, ExtraCombination and combination_hash to the vehicles.models import, ExtraCombinationSerializer to the serializer import.
Helper above VehicleViewSet (next to _maybe_finalize, matching file convention):
def _record_extra_combination(extra_data):
"""Record the lookup-only projection of a vehicle's extra data as a reusable combination."""
if not extra_data:
return
lookup_codes = set(
Nomenclature.objects.filter(applies_to='vehicle', kind=Nomenclature.LOOKUP)
.values_list('code', flat=True)
)
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
if not combo:
return
existing, created = ExtraCombination.objects.get_or_create(
data_hash=combination_hash(combo), defaults={'data': combo}
)
if not created:
existing.use_count += 1
existing.last_used = timezone.now()
existing.save()
action = 'created' if created else 'updated'
sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data)
get_or_create (rather than filter().first() + create()) closes the race where two users save the same brand-new combination simultaneously — otherwise one gets an IntegrityError 500 after the vehicle was already saved.
Two guards matter: no extra at all, and extra containing only kind='field' values (the common "user typed a one-off note" case). Neither should create a row.
ViewSet after VehicleViewSet:
class ExtraCombinationViewSet(mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet):
"""
API endpoint for previously-used combinations of vehicle extra (lookup) data.
Rows are created/bumped automatically when a vehicle's extra data is saved.
"""
queryset = ExtraCombination.objects.all()
serializer_class = ExtraCombinationSerializer
permission_classes = [IsAuthenticated]
ordering = ['-last_used']
def perform_destroy(self, instance):
data = ExtraCombinationSerializer(instance).data
instance.delete()
sse_broadcast_update('extra_combination', 'deleted', data)
No create/update endpoints — rows only ever appear as a side effect of saving a vehicle.
Route in api/urls.py:
router.register(r'extra-combinations', views.ExtraCombinationViewSet, basename='extra-combination')
4. Where recording fires — VehicleViewSet
Backend, in perform_create/perform_update. This is the layer that already owns side effects of a successful vehicle mutation (sse_broadcast_update), it covers both required paths (create-with-extra, and the handleSaveExtra PATCH which routes through partial_update → perform_update), and it can't be forgotten by frontend code.
Rejected: a frontend POST (two round-trips, fails independently, must be duplicated, wrong trust boundary); a post_save signal (would fire on fixtures, migrations, shell saves and admin edits, broadcasting SSE with no request context — and the project has zero signals today).
One line added to each, at api/views.py:161-169:
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
hasattr matches the existing style at api/serializers.py:162; extra is a reverse OneToOne that raises DoesNotExist when absent.
set_tare/set_gross bypass perform_update and don't touch extra data — leave them alone.
Frontend
5. New component — frontend/src/components/ExtraCombinationsTable.jsx + .css
Sits in components/ next to Main.jsx (it's a Main-screen helper, not a nomenclature UI primitive).
Props: { nomenclatures, onApply } — nomenclatures is the already-sorted vehicleNomenclatures from Main.jsx:28 so columns line up with the dropdowns below; onApply(data) gets the raw dict.
Data + realtime: local state + fetch on mount + subscribeToUpdates. Extending NOMENCLATURES_CONFIG was rejected — that machinery sorts by name/id (not -last_used) and its handler has no delete branch, so it'd mean modifying shared load-bearing code for one consumer.
This needs one token added to NomenclatureContext.jsx:362, since unknown objects currently hit console.warn and get dropped:
if (object === 'nomenclature' || object === 'nomenclature_entry' || object === 'extra_combination') {
Strictly additive — existing subscribers already filter on message.object before acting.
Stale-closure hazard: subscribeToUpdates is not useCallback'd (NomenclatureContext.jsx:344), so a useEffect depending on it re-subscribes every provider render. Hold it in a ref and run the effect with [] deps. Leave a one-line comment so nobody "fixes" it back.
Key logic:
const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup');
const load = useCallback(async () => {
const res = await api.get('/api/extra-combinations/');
setCombinations(res.data.results || res.data); // PAGE_SIZE=100 envelope
}, []);
// SSE
if (message.object !== 'extra_combination') return;
setCombinations(prev => operation === 'delete'
? prev.filter(c => c.id !== data.id)
: [data, ...prev.filter(c => c.id !== data.id)]); // most recent first
Table: one <th> per lookup nomenclature (nom.name) plus a narrow actions column; rows call onApply(combo.data); each row has a × delete button with e.stopPropagation() and a window.confirm. Cells resolve via getDisplayValue(nom.code, entryId), rendering #<id> in muted italic when an id no longer resolves. Returns null if there are no lookup nomenclatures.
CSS follows the nui-mgmt-* table conventions from NomenclatureManagementOverlay.css, scaled down for inline use: sticky header on #f8f9fa, 1px solid #dee2e6 row borders, #f0f0ff hover, max-height: 180px + overflow-y: auto, overflow-x: auto with white-space: nowrap, uppercase 0.75rem headers, 6px radii, #dc3545 delete hover.
6. Main.jsx — apply handler + placement
// Apply a saved combination - merge so non-lookup field values are preserved
const handleApplyCombination = (data) => {
setExtraData(prev => ({ ...prev, ...data }));
};
Merge, not replace. data only contains lookup codes (enforced server-side), so spreading over prev preserves every free-text/number/bool value the user already typed. Lookup fields absent from the combination keep their current value rather than being cleared — the safer default, never silently destroying input.
Placement inside the existing edit-form .extra-section at Main.jsx:417-442, directly above .extra-fields:
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<div className="extra-fields">
Both forms. The same table renders in the new-vehicle form and the edit form, each paired with its own state via a sibling handler:
const handleApplyCombinationNew = (data) => {
setNewExtraData(prev => ({ ...prev, ...data }));
};
Edge cases
| Case | Behaviour | Action |
|---|---|---|
| Entry deactivated after being saved in a combination | getDisplayValue searches all entries, so the label still renders. Save fails loudly with Entry ID N not found, caught by the existing handleSaveExtra error path. |
None. Deactivation is soft and reversible; hiding rows would make them vanish and reappear. |
| Entry hard-deleted | Cell flips live to muted italic #<id> (SSE removes it from the cache). |
Render the marker so the broken row is visible and deletable — hiding it would remove the only way to clean it up. |
| Nomenclature deleted | Its column disappears from the table; the orphan key stays in data, unrendered. Clicking such a row would fail validation on save with Unknown nomenclature code. |
None. Admin-rare, self-resolving on the next fresh save. A cleanup signal would be speculative machinery. |
| Validation failure on click-apply | Impossible — clicking is local setState only, no network. Errors surface at save via the existing .vehicle-error banner. |
None. Don't special-case; this is how every other extra-data error already behaves. |
| Concurrent identical saves | Handled by get_or_create (§3). |
Done. |
| Empty projection | Two guards in the recorder + serializer rejects {}. |
Done. |
Verification
Backend on 8002 via run_server.bat; frontend via npm start in frontend/ (port 5172).
Preflight: confirm frontend/.env sets REACT_APP_API_URL=http://localhost:8002 — NomenclatureContext.jsx:3 defaults to :8000, so a wrong value makes SSE silently dead while REST still works through the proxy. Confirm http://localhost:5172 is in CORS_ALLOWED_ORIGINS.
cd backend && ..\.venv\Scripts\activate
python manage.py makemigrations vehicles && python manage.py migrate
python manage.py showmigrations vehicles :: expect [X] 0005_extracombination
Needs ≥2 vehicle lookup nomenclatures with active entries (e.g. CARGO, SPEDITOR) and one kind='field' text nomenclature (e.g. NOTE) for step 5.
- Empty state — select a vehicle; "Previously Used" shows No saved combinations yet with correct columns.
- First save creates a row — Cargo=Coal, Speditor=ACME → Save Extra Data. Row appears with no page reload.
- Dedup — same combination on a different vehicle → still exactly one row. Verify:
Expect
python manage.py shell -c "from vehicles.models import ExtraCombination; [print(c.id, c.data, c.use_count, c.last_used) for c in ExtraCombination.objects.all()]"use_count=2, freshlast_used. - Click-apply — clear both dropdowns, click the row → both populate. Save → one row,
use_count=3. - Merge preserves free-text (most important regression check) — type text into
NOTE, click a combination row. The note must survive, andNOTEmust have no column in the table. - Ordering — save a second distinct combination; it appears above. Re-save the first → it returns to the top.
- Delete over SSE — open a second browser tab first, then delete a row via
×. It must disappear in both tabs (this is the pathNomenclatureContextdoesn't natively support, so most likely to break). - New-vehicle path — create a vehicle with extra data via "+ New Vehicle" → row created (verifies
perform_create). - Empty projection — create a vehicle with only
NOTEfilled → no new row; confirm count unchanged in the shell. - Stale id — hard-delete a referenced entry via the
…overlay. Cell flips to#<id>live; clicking + saving shows theEntry ID N not founderror;×removes the row. - Key-order independence —
Expect
python manage.py shell -c "from vehicles.models import combination_hash; print(combination_hash({'A':1,'B':2}) == combination_hash({'B':2,'A':1}))"True. - API surface —
GET /api/extra-combinations/returns the paginated envelope ordered by-last_used;POSTreturns 405.
Files changed
Backend
backend/vehicles/models.py— imports,combination_hash(),ExtraCombinationbackend/vehicles/migrations/0005_extracombination.py— generatedbackend/api/serializers.py— extractvalidate_vehicle_extra_data(), slimVehicleExtraSerializer.validate_data, addExtraCombinationSerializerbackend/api/views.py— imports,_record_extra_combination(), 1 line each inperform_create/perform_update,ExtraCombinationViewSetbackend/api/urls.py— 1 router line
Frontend
frontend/src/components/ExtraCombinationsTable.jsx— newfrontend/src/components/ExtraCombinationsTable.css— newfrontend/src/components/Main.jsx— import,handleApplyCombination+handleApplyCombinationNew, JSX insert in both formsfrontend/src/contexts/NomenclatureContext.jsx— 1 token at line 362
Untouched: vehicles/views.py, vehicles/serializers.py, vehicles/urls.py (dead code), nomenclatures/, Main.css.