Files
scalesApp/.claude/plans/single-measure-button.md
T
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

177 lines
10 KiB
Markdown

# 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