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

17 KiB
Raw Permalink Blame History

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:

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.

  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:
    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 —
    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.