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>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user