# 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_manual` booleans 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. ```python 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`: ```python 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 becomes `tare` and 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`: ```python @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.""" ``` - `value` required, coerced to `int` — same validation as the current `set_tare`. - `manual` (bool, default false) and `note` (string, optional) in the body. - **Permissions**: reject unless `request.user.can_measure`; additionally require `can_manually_measure` when `manual` is true. These flags exist on `User` and 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: ```jsx 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 `manual` marker 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`). 1. **Migration** — `python manage.py showmigrations vehicles` shows `[X] 0006_...`. 2. **Inbound** — new vehicle, measure 8000 → "First weighing" card, not Tare. Measure 15000 → Tare 8000, Gross 15000, Net 7000. 3. **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. 4. **Equal** — 10000 twice → Net 0, finalized. 5. **Third press blocked** — button disabled; direct POST returns 400. 6. **Permissions** — user without `can_measure` gets 403 and no button; without `can_manually_measure`, no manual trigger. 7. **Manual** — record manually with a note; confirm `*_manual`, `*_manual_user`, `*_manual_date`, `*_manual_note` in the shell. Blank note → 400. 8. **Provenance after swap** — the shell check that matters: ``` 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)" ``` Each weight must carry the date/user of the press that produced it. 9. **Old endpoints gone** — POST to `set-tare` returns 404. 10. **Build** — `npm run build` compiles with no new warnings. --- ## Files changed **Backend** - `backend/vehicles/models.py` — 6 fields on `Vehicle` - `backend/vehicles/migrations/0006_...py` — generated - `backend/api/serializers.py` — fields on `VehicleSerializer`, read-only - `backend/api/views.py` — `_assign_weighing()`, `measure` action, delete `set_tare`/`set_gross` **Frontend** - `frontend/src/components/Main.jsx` — `handleMeasure`, manual modal, rewritten weighing section - `frontend/src/components/Main.css` — manual marker style