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>
10 KiB
Single "Measure" Button + Manual Measurement
Context
The weighing screen has two buttons, Set Tare and Set Gross. Operators regularly press the wrong one. Since the vehicle is on the scale either empty or loaded, the app can decide which is which — the operator shouldn't have to.
This replaces both with a single Measure button. The first press records a weighing; the second press records the other and finalizes the document. Assignment is by weight, not by order.
A second, related need: sometimes a weight must be entered by hand (scale offline, disputed reading). That must be recorded as manual, with who, when and why.
Decisions made with the user:
- Site handles both directions — trucks may arrive loaded and leave empty. So order can't decide; lighter = tare, heavier = gross, regardless of which was taken first.
- Pressing Measure on an already-finalized vehicle is blocked (button disabled), matching today's behaviour. No silent mutation of an issued document.
- No clear/redo. Scales expose a "balanced" flag and measuring will later be gated on it. Not in this change.
- Manual measurement is built now, extending the existing
tare_manual/gross_manualbooleans with user, date and note per measurement.
Why weight-based assignment
net = gross - tare, so assigning the lighter reading to tare makes a negative net structurally impossible. Order-based assignment (first = tare) would produce a negative net for every outbound truck — which the user confirmed happens here.
The tradeoff to be aware of: for two weighings the rule is unambiguous, but it means the first reading's role is not final until the second arrives. A truck weighed at 15000 shows as tare; if the next weighing is 8000, the 15000 becomes gross and the 8000 becomes tare. The UI must not present the first reading as settled.
Handling: while only one weighing exists, display it in a single neutral card labelled "First weighing" rather than filling in the Tare card. Once the second arrives, both cards populate with their final roles. This avoids a value visibly jumping between cards, which would look like a bug.
Equal weights (gross == tare) yield net = 0. Physically it means nothing was loaded or unloaded. Treat as valid and let it finalize — rejecting it would strand the vehicle with no way forward, and a zero-net document is a legitimate record of a truck that came and went unchanged.
Backend
1. Model — backend/vehicles/models.py
Extend the existing unused tare_manual / gross_manual booleans with companions. Keep them adjacent to the weight group they describe, matching the file's existing layout.
tare_manual = models.BooleanField(default=False)
tare_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare_manual', on_delete=models.SET_NULL)
tare_manual_date = models.DateTimeField(null=True, blank=True)
tare_manual_note = models.CharField(max_length=255, null=True, blank=True)
and the gross_ equivalents (related_name='user_vehicle_gross_manual').
related_name must not collide with the existing user_vehicle_tare / user_vehicle_gross.
Migration: python manage.py makemigrations vehicles → 0006_... (latest is now 0005_extracombination). All fields nullable with defaults, so no interactive prompt.
2. Assignment helper — backend/api/views.py
Add beside _maybe_finalize:
def _assign_weighing(vehicle, value, user, manual=False, note=None):
"""Record a weighing. Assigns by weight: lighter becomes tare, heavier becomes gross.
First weighing is parked in tare; the second may swap it if it turns out lighter,
since trucks may arrive either loaded (outbound) or empty (inbound).
"""
Logic:
- Neither set — park in
tare(tare_date,tare_user, and the manual fields when manual). Role is provisional until the second weighing. - One set (tare only) — compare against the parked value. If the new reading is heavier or equal, it becomes
gross. If lighter, it becomestareand the parked values move to gross — carrying their own date, user and manual fields with them, so provenance follows the value rather than the slot. Then_maybe_finalize. - Both set — raise; the viewset returns 400. Should be unreachable from the UI (button disabled) but the API must not be trusted to be called only by the UI.
The swap must move all companion fields together (*_date, *_user, *_manual, *_manual_user, *_manual_date, *_manual_note). Missing one silently misattributes who took a reading — the most likely bug in this change, and invisible until someone audits a document.
3. Endpoint — backend/api/views.py
One new action on VehicleViewSet, replacing set_tare / set_gross:
@action(detail=True, methods=['post'], url_path='measure')
def measure(self, request, pk=None):
"""Record a weighing. The app assigns tare vs gross by weight."""
valuerequired, coerced toint— same validation as the currentset_tare.manual(bool, default false) andnote(string, optional) in the body.- Permissions: reject unless
request.user.can_measure; additionally requirecan_manually_measurewhenmanualis true. These flags exist onUserand are editable in UserManager but are currently enforced nowhere — this is the first enforcement. 403 with a clear detail message. - Manual requires a note — reject blank. A manual override without a reason is not worth recording.
- Returns the serialized vehicle and broadcasts
sse_broadcast_update('vehicle', 'updated', ...), matching the existing pattern.
Delete set_tare and set_gross. They bypass the assignment rule, so leaving them routed would leave the wrong-value path open — the exact problem being fixed. Nothing else calls them (verified: only Main.jsx).
4. Serializer — backend/api/serializers.py
Add the six new fields to VehicleSerializer.fields and to read_only_fields — they are set only through the measure action, never by direct PATCH.
Frontend
5. Main.jsx — replace the two handlers
handleSetTare and handleSetGross (lines 122-140) collapse into:
const handleMeasure = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, { value: currentWeight });
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
}
};
Note the current handlers swallow the server message (catch { setError('Failed to set tare') }). Surface detail instead, so permission and already-finalized errors are legible.
Manual entry: a small modal (weight, required note) posting the same endpoint with manual: true. Trigger shown only when currentUser?.can_manually_measure, using the useAuth() pattern from NomenclatureManager.
6. Main.jsx — weighing section JSX
Replace the two weight-card blocks (lines 362-401). Three states:
- No weighings — one neutral card,
---, with the Measure button. - One weighing — one card headed "First weighing" showing value, timestamp, user, and a
manualmarker if applicable. Measure button still enabled. Deliberately not labelled Tare. - Both — Tare and Gross cards as today, each showing its own meta, plus the existing Net card. Measure button disabled.
Button disabled when: no live weight, or both weights set. Gate visibility on can_measure.
Reuse the existing .weight-card, .weight-card-header, .weight-card-value, .weight-card-meta, .weight-set-btn classes — no new CSS beyond a modifier for the manual marker.
Edge cases
| Case | Behaviour |
|---|---|
| Second reading lighter than the first | New value becomes tare; parked values move to gross with all their metadata. Net positive. |
| Second reading equal | Becomes gross, net = 0, finalizes. Valid record. |
| Measure on a finalized vehicle | Button disabled; API returns 400 if called directly. |
User lacks can_measure |
403. Button hidden. |
Manual without can_manually_measure |
403. Trigger hidden. |
| Manual with blank note | 400. |
| Two operators measuring the same vehicle at once | Last write wins, as today. Out of scope — flagged, not fixed. |
Verification
Backend on 8002 (run_server.bat), frontend on 5172 (npm start).
- Migration —
python manage.py showmigrations vehiclesshows[X] 0006_.... - Inbound — new vehicle, measure 8000 → "First weighing" card, not Tare. Measure 15000 → Tare 8000, Gross 15000, Net 7000.
- Outbound (the case order-based logic breaks) — measure 15000 first, then 8000 → Tare 8000, Gross 15000, Net 7000, positive. Confirm the 15000's original date/user followed it to gross.
- Equal — 10000 twice → Net 0, finalized.
- Third press blocked — button disabled; direct POST returns 400.
- Permissions — user without
can_measuregets 403 and no button; withoutcan_manually_measure, no manual trigger. - Manual — record manually with a note; confirm
*_manual,*_manual_user,*_manual_date,*_manual_notein the shell. Blank note → 400. - Provenance after swap — the shell check that matters:
Each weight must carry the date/user of the press that produced it.
python manage.py shell -c "from vehicles.models import Vehicle; v=Vehicle.objects.get(vehicle_number='X'); print(v.tare, v.tare_date, v.tare_user, v.tare_manual); print(v.gross, v.gross_date, v.gross_user, v.gross_manual)" - Old endpoints gone — POST to
set-tarereturns 404. - Build —
npm run buildcompiles with no new warnings.
Files changed
Backend
backend/vehicles/models.py— 6 fields onVehiclebackend/vehicles/migrations/0006_...py— generatedbackend/api/serializers.py— fields onVehicleSerializer, read-onlybackend/api/views.py—_assign_weighing(),measureaction, deleteset_tare/set_gross
Frontend
frontend/src/components/Main.jsx—handleMeasure, manual modal, rewritten weighing sectionfrontend/src/components/Main.css— manual marker style