fe9fb71860
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>
322 lines
17 KiB
Markdown
322 lines
17 KiB
Markdown
# 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_used` and increments `use_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:
|
||
|
||
```python
|
||
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](backend/api/serializers.py#L85-L123) into a module-level function with a `lookup_only` flag, so both serializers share it. Behaviour for the existing path is bit-identical.
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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`):
|
||
|
||
```python
|
||
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):
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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](backend/api/urls.py):
|
||
```python
|
||
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](backend/api/views.py#L161-L169):
|
||
|
||
```python
|
||
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
|
||
```
|
||
|
||
`hasattr` matches the existing style at [api/serializers.py:162](backend/api/serializers.py#L162); `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](frontend/src/components/Main.jsx#L28) 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](frontend/src/contexts/NomenclatureContext.jsx#L362), since unknown objects currently hit `console.warn` and get dropped:
|
||
|
||
```jsx
|
||
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](frontend/src/contexts/NomenclatureContext.jsx#L344)), 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:
|
||
|
||
```jsx
|
||
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](frontend/src/components/NomenclatureUI/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
|
||
|
||
```jsx
|
||
// 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](frontend/src/components/Main.jsx#L417-L442), directly above `.extra-fields`:
|
||
|
||
```jsx
|
||
<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:
|
||
|
||
```jsx
|
||
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](frontend/src/contexts/NomenclatureContext.jsx#L3) 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`.
|
||
|
||
```bat
|
||
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.
|
||
|
||
1. **Empty state** — select a vehicle; "Previously Used" shows *No saved combinations yet* with correct columns.
|
||
2. **First save creates a row** — Cargo=Coal, Speditor=ACME → Save Extra Data. Row appears with no page reload.
|
||
3. **Dedup** — same combination on a *different* vehicle → still exactly one row. Verify:
|
||
```bat
|
||
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()]"
|
||
```
|
||
Expect `use_count=2`, fresh `last_used`.
|
||
4. **Click-apply** — clear both dropdowns, click the row → both populate. Save → one row, `use_count=3`.
|
||
5. **Merge preserves free-text** *(most important regression check)* — type text into `NOTE`, click a combination row. **The note must survive**, and `NOTE` must have no column in the table.
|
||
6. **Ordering** — save a second distinct combination; it appears above. Re-save the first → it returns to the top.
|
||
7. **Delete over SSE** — open a second browser tab first, then delete a row via `×`. It must disappear in **both** tabs (this is the path `NomenclatureContext` doesn't natively support, so most likely to break).
|
||
8. **New-vehicle path** — create a vehicle with extra data via "+ New Vehicle" → row created (verifies `perform_create`).
|
||
9. **Empty projection** — create a vehicle with only `NOTE` filled → **no new row**; confirm count unchanged in the shell.
|
||
10. **Stale id** — hard-delete a referenced entry via the `…` overlay. Cell flips to `#<id>` live; clicking + saving shows the `Entry ID N not found` error; `×` removes the row.
|
||
11. **Key-order independence** —
|
||
```bat
|
||
python manage.py shell -c "from vehicles.models import combination_hash; print(combination_hash({'A':1,'B':2}) == combination_hash({'B':2,'A':1}))"
|
||
```
|
||
Expect `True`.
|
||
12. **API surface** — `GET /api/extra-combinations/` returns the paginated envelope ordered by `-last_used`; `POST` returns **405**.
|
||
|
||
---
|
||
|
||
## Files changed
|
||
|
||
**Backend**
|
||
- `backend/vehicles/models.py` — imports, `combination_hash()`, `ExtraCombination`
|
||
- `backend/vehicles/migrations/0005_extracombination.py` — generated
|
||
- `backend/api/serializers.py` — extract `validate_vehicle_extra_data()`, slim `VehicleExtraSerializer.validate_data`, add `ExtraCombinationSerializer`
|
||
- `backend/api/views.py` — imports, `_record_extra_combination()`, 1 line each in `perform_create`/`perform_update`, `ExtraCombinationViewSet`
|
||
- `backend/api/urls.py` — 1 router line
|
||
|
||
**Frontend**
|
||
- `frontend/src/components/ExtraCombinationsTable.jsx` — new
|
||
- `frontend/src/components/ExtraCombinationsTable.css` — new
|
||
- `frontend/src/components/Main.jsx` — import, `handleApplyCombination` + `handleApplyCombinationNew`, JSX insert in both forms
|
||
- `frontend/src/contexts/NomenclatureContext.jsx` — 1 token at line 362
|
||
|
||
Untouched: `vehicles/views.py`, `vehicles/serializers.py`, `vehicles/urls.py` (dead code), `nomenclatures/`, `Main.css`.
|