Files
scalesApp/.claude/plans/extra-data-quick-fill-combinations.md
gitea fe9fb71860 Add implementation plans and remove stray Word temp file
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>
2026-09-18 18:58:38 +03:00

322 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.