28 KiB
Finalize-on-print, History view, and unified report preview modal
Context
Today, the moment a vehicle's second weighing lands, the backend silently
computes net and assigns a doc_number (_maybe_finalize,
api/views.py:134-141) — but nothing in the
UI reflects this as an event. The vehicle just sits in the active list
forever, indistinguishable from an in-progress one except that its action
column now shows Print/Design instead of Measure.
The user's actual workflow (matching their existing Delphi weighing software,
and the problem it has that they want to avoid repeating): once a truck is
weighed twice, the operator opens a report preview, prints or downloads the
notice, and the truck should then leave the active list — because it's done,
and a busy station's list should only show trucks still being processed. If a
print physically fails (paper jam, printer offline — a real, frequent
occurrence per the user, and one the browser's window.print() can never
report back on, confirmed via afterprint's fundamental limitation), the
operator needs to get back to that vehicle's report without digging through a
separate/hidden screen. A History view listing finalized vehicles, with
the same print/download controls available there, is exactly that recovery
path — and it becomes the only path back once a vehicle leaves the active
list, so it must be trivial to reach, not buried.
This session's exploration surfaced two things that must be fixed as part of this work, not deferred:
driver_nameis dead on arrival. The live vehicle API isapi/views.py+api/serializers.py(registered inapi/urls.py) — NOTvehicles/views.py/vehicles/serializers.py, which are orphaned (not routed) and contain thedriver_name-required work from the previous session.driver_nameis missing entirely fromVehicleSerializer.Meta.fieldsin the real, liveapi/serializers.py:165. The frontend's create-vehicle form already sendsdriver_name, but the live backend silently drops it on the floor.- The frontend never removes anything from
vehicles, and SSE has no working delete path.NomenclatureContext.handleSSEUpdate(NomenclatureContext.jsx:407-432) only handlesinsert/update; adelete/archiveoperation is silently dropped even though the backend'soperation_mapalready produces'delete'for a'deleted'action. Any "vehicle leaves the list in real time, on every open tab" behavior needs this added — it is not pre-existing plumbing we can just call.
Decisions made this session (do not re-litigate)
- driver_name: nullable at the DB level (already migrated), required on
create at the serializer level — fix belongs in
api/serializers.py(the live one), same validation approach already proven correct in the orphanedvehicles/serializers.py(object-levelvalidate(), gated onself.instance is None, because a per-fieldvalidate_driver_namehook was tested and found to skip validation when the key is omitted entirely). - When a vehicle leaves the active list: on the operator's explicit
Print or Download PDF click inside the new preview modal — not
automatically the moment
net/doc_numberare assigned server-side. The user was explicit: "when press it - the truck is given a document number, disappears from list, the preview opens with print/download buttons." Concretely: the modal opens as soon as Print Notice is clicked (same trigger as today), and the archive+doc_number-visible transition happens when the operator acts inside that modal (Print or Download PDF), not on the initial open. Opening the modal must not, by itself, remove the truck — an operator who opens it and then closes it without printing must find the truck still there, still able to re-measure if needed. - Preview modal layout (replaces
ReportPreviewModal's current bare preview-only body): top bar with a matrix/A4 kind switcher on the left and Download PDF on the right; body renders the selected kind's preview; footer bar with centered Print and Close buttons. The switcher is real, not a placeholder:notice_reportcan have two actual savedReportrows (onekind='matrix', onekind='a4') side by side — confirmed the model already allows this (namehas no uniqueness constraint) — and the switcher toggles between whichever of the two the operator has designed. If only one kind has been saved, the other option shows a disabled/empty state pointing at Design Notice for that kind. Default kind shown on open = the kind that was open/used last, or the first one that exists. - Download PDF: REVERSED from an earlier draft of this plan — no
server-side rendering. Originally scoped as Playwright (Python) driving
headless Chromium; abandoned once an existing plan
(a4-report-designer.md, §"Why
Puppeteer is deferred (not rejected)") surfaced two concrete costs specific
to this deployment that the earlier draft hadn't weighed:
- Fonts. A4 notices print with whatever font is installed on the
operator's station — that's the whole point of using
window.print()/@pageCSS instead of a PDF library (see that same plan's "Why browser printing, not a PDF library"). Server-side rendering uses the server's fonts instead. At this project's actual reference deployment (one server, ten stations, per deployment-windows-installer), that's the wrong machine — a server-rendered PDF could visibly mismatch what prints at the station, forever, unless fonts are duplicated and kept in sync server-side. - Puppeteer/Playwright earns its cost when a PDF must be produced with
no human in the loop (auto-archiving, emailed tickets, batch jobs).
Download PDF has an operator clicking a button and waiting — exactly
the case
window.print()→ "Save as PDF" already covers, today, with zero new services and zero font-mismatch risk. Decided approach: Download PDF triggers the SAMEwindow.print()flow already used for Print (printA4Reportfor a4, the new print-root-append flow from Phase C for matrix), just labelled/framed toward the "Save as PDF" destination rather than physical printing. No Playwright, no Puppeteer, no new backend endpoint, no offline-installer packaging cost. This also means Phase D as originally scoped (a new PDF microservice) is not needed — see the rewritten Architecture section below.
- Fonts. A4 notices print with whatever font is installed on the
operator's station — that's the whole point of using
- Print button in the modal (and Download PDF, per the reversal above)
both use today's
window.print()/printA4Report()flow for A4, and the Phase C print-root-append flow for matrix. No further backend work. - History view: a new page/route, reachable from the main nav (not
hidden), listing finalized vehicles (those with
doc_numberset) — most recent first. Each row opens the same unified preview modal used from the main weighing screen, so reprint/redownload is the identical component and code path, not a second implementation. - Nothing is ever deleted. Explicit, standing rule for this feature and
beyond: a vehicle record is never destroyed. There are three real states —
in progress (active list), completed (History, has
doc_number), and voided (History, flagged but still fully visible/queryable) — and moving between them is always a flag/field change, never aDELETE. This also means the orphanedvehicles/views.py'sperform_destroy(confirmed at api/views.py's pattern — check whetherVehicleViewSetexposes a working DELETE at all; if it does, it is now out of step with this rule and should be disabled, flagged to the user rather than silently left reachable). - Void ("сторно"/reversal): an operator-triggered action available only
from History, only on an already-completed record — not on an
in-progress vehicle, and not a bulk/automatic thing. Marks a completed
weighing as voided when it turns out to be wrong (wrong truck, bad
measurement, mistake caught after printing) — the record, its doc_number,
and its full weighing history stay intact and visible in History,
permanently flagged, distinguishable from a normal completed record at a
glance (e.g. a visual badge, per how
.vehicle-status--*already distinguishes states in the hero bar, Main.css:189-203). vehicle_numberuniqueness must change. Today it's a plainunique=Truecolumn (vehicles/models.py:11) — global, forever. Combined with "nothing is ever deleted," this breaks the very next time a real truck (plate reused constantly) returns to the scale after its first weighing completes: creating the new active record would fail on a uniqueness violation. Decided fix: unique only among in-progress vehicles — a truck can't be on the scale twice at once, but its plate can appear any number of times across History. Implemented as a Django partialUniqueConstraint, not a plain column constraint:(Django 6.0, confirmed installed —class Meta: constraints = [ models.UniqueConstraint( fields=['vehicle_number'], condition=models.Q(doc_number__isnull=True), name='unique_active_vehicle_number', ), ]condition=onUniqueConstrainthas been supported since Django 2.2, compiles to a PostgreSQL partial unique index, which this project's Postgres backend fully supports.) Requires dropping the existingunique=Trueon the field itself and adding this constraint instead — a real migration, not a no-op. Confirm during implementation whether "in-progress" should be defined asdoc_number__isnull=Truealone, or also exclude voided-but-somehow- doc_number-less edge cases — should not arise if void is only ever triggered on already-completed (doc_number-bearing) records, per the Void scope decision above, but worth a sanity check against real data.
Architecture
Backend
1. Fix the live serializer — backend/api/serializers.py:
- Add
'driver_name'toVehicleSerializer.Meta.fields(currently missing at line 165). - Add the required-on-create
validate()override, ported from the pattern already proven in the orphanedvehicles/serializers.py:(Ifdef validate(self, attrs): if self.instance is None and not attrs.get('driver_name'): raise serializers.ValidationError({'driver_name': 'This field is required.'}) return attrsVehicleSerializeralready defines avalidate(), merge into it rather than adding a second one.) - Leave the orphaned
vehicles/app alone — it's unrouted dead code, not this task's problem to clean up (flag to the user separately; don't delete without being asked, per project convention).
2. No PDF endpoint needed — REVERSED, see the Download PDF decision above. No new backend endpoint, no Playwright, no server-side rendering. Phase D on the backend side is a no-op; everything below is frontend-only.
3. Print-intent signal for both the modal's Print AND Download PDF
buttons: both now trigger the existing client flow
(window.print()/printA4Report for a4, the Phase C print-root-append flow
for matrix — Download PDF is the same call, just aimed at the "Save as PDF"
destination the operator picks in that same dialog), which the backend can't
observe directly.
Add a lightweight endpoint, e.g. POST /api/vehicles/{id}/mark-notice-printed/,
called by the frontend right when the operator clicks Print in the modal (not
waiting on afterprint, since that signal is untrustworthy per this
session's earlier discussion) — its only job is to broadcast the SSE removal
event (below). It does not need to change any DB field; doc_number already
being set is the source of truth for "this vehicle belongs in History, not
the active list." This keeps "print succeeded" and "should leave the active
list" cleanly decoupled, exactly as discussed: a failed physical print still
correctly moves the truck to History (where it's one click to reprint), it
does not silently strand it in a state nobody can find.
4. SSE removal broadcast: wherever the "moved to history" signal fires
(the mark-notice-printed endpoint, and/or the new PDF endpoint on
successful generation), call
sse_broadcast_update('vehicle', 'archived', serializer.data) — a new,
distinct action string (not reusing 'deleted', since the vehicle record
itself isn't deleted, only its presence in the active list changes) — see
frontend section for how this action is consumed. Using
sse_broadcast_update, same call shape
already used by perform_create/perform_update.
5. Filter the active-list endpoint (optional but recommended for
correctness/scale): today VehicleViewSet.queryset returns all vehicles
unconditionally. Once History exists, the main screen's vehicle list should
probably query only non-finalized vehicles server-side too (e.g.
?doc_number__isnull=true), not rely purely on client-side SSE-driven
removal — SSE gives real-time updates for tabs already open, but a fresh
page load should not have to fetch every historical vehicle ever weighed
just to filter them out client-side. Add a query param the frontend's main
list fetch already passes (NomenclatureContext's vehicle endpoint config,
NomenclatureContext.jsx:9),
and a separate History fetch that explicitly requests
?doc_number__isnull=false, ordered newest-first (-net_date or similar).
6. is_void field + partial-uniqueness migration — the Vehicle model
itself lives at backend/vehicles/models.py regardless of which app's
views/serializers are live (there is only ONE Vehicle model — vehicles/
being "orphaned" refers only to that app's OWN views.py/serializers.py,
not the model, which api/views.py imports and uses directly). So this
migration correctly targets backend/vehicles/models.py, but the
@action/serializer changes below belong in api/views.py/api/serializers.py
(the live ones), matching how driver_name and everything else in this plan
is split: model in vehicles/, live API surface in api/.
- Add
is_void = models.BooleanField(default=False), plusvoid_date/void_user/void_reasonfollowing the same date/user/note provenance pattern already used fortare/gross/manual entries elsewhere on this model (e.g.tare_manual_user,tare_manual_date,tare_manual_note, vehicles/models.py:19-22) — reuse that established shape rather than inventing a different one for void. - Replace
vehicle_number = models.CharField(max_length=15, unique=True)with the plain field (dropunique=True) plus the partialUniqueConstraintfrom the decision above, in one migration. - New
@actiononVehicleViewSet, e.g.POST /api/vehicles/{id}/void/(mirroringmeasure's permission-gated pattern) — setsis_void,void_date,void_user, accepts a requiredreasonin the request body (store it; an operator voiding a printed document should always leave a reason, same spirit asgross_manual_note/tare_manual_notealready require context for a manual override). Rejects (400) ifdoc_numberis not set — void only applies to completed records, per the scope decision above. This does NOT setdoc_numberto null or touchnet/tare/gross— everything about the original weighing stays exactly as it was, onlyis_voidchanges. Broadcastssse_broadcast_update('vehicle', 'voided', serializer.data)so any open History view updates live too. VehicleSerializerneedsis_void/void_date/void_reasonadded toMeta.fields(read-only except through the dedicated void action).
Frontend
1. NomenclatureContext.jsx — add a delete/archive branch to
handleSSEUpdate's switch
(NomenclatureContext.jsx:407-432),
handling the new 'archived' action (mapped through operation_map on the
backend, or handled as a raw fallback string if not added to that map — check
which is simpler when implementing) by removing the vehicle from the
in-memory vehicles array. This is the one piece of "real-time removal"
plumbing that doesn't exist anywhere today and is the crux of making
multi-station "truck vanishes everywhere at once" actually work.
2. Unified ReportPreviewModal — restructure
ReportPreviewModal.jsx (and
its .css) into three regions instead of the current bare header+body:
- Top bar: left = matrix/A4 toggle, switching between two genuinely
separate stored
Reportrows sharing the samename='notice_report'but differentkind. Confirmed this needs no schema change:Report.namehas no uniqueness constraint (api/models.py:45-75), so a matrix row and an a4 row can already coexist under the same name — nothing stops it today. The gap is entirely on the fetch/lookup side:fetchNoticeReport()(Main.jsx:357-360) currently does.find(r => r.name === 'notice_report'), returning whichever row the API lists first — it has no way to ask for "the matrix one" vs "the a4 one." Change it to accept akindargument and filter accordingly (or add a backend query param,?name=notice_report&kind=a4, sinceReportViewSetalready hasfilterset_fields = ['name', 'kind']— the backend filter already exists, this is a client-side fix only).- The modal fetches BOTH kind-variants on open (two lookups, or one list
call filtered by name only, then split client-side by
kind), so the switcher can toggle without a re-fetch. If one kind doesn't exist yet (operator never designed an A4 version), that switcher option is disabled/shows an empty state with a link to Design Notice for that kind. handleDesignNotice/handleDesignAs(Main.jsx:403-449) already let an operator explicitly open either editor regardless of what's saved (handleDesignAs('matrix'|'a4')) — this already supports creating the other kind's report under the same name; the Report Editor's own save path (handleSave, ReportEditor.jsx:276-297) does a plainPOSTwhenreportIdis unset, so saving a second, different-kindnotice_reportis already a supported write path with no backend changes needed there either. Right = Download PDF button — REVERSED, see the top-level decision above: no backend endpoint, no blob download. Triggers the samewindow.print()/printA4Report(a4) or print-root-append (matrix) flow as the Print button, so the operator reaches PDF via "Save as PDF" in that same dialog, using the station's own installed fonts. (Phase C shipped this button as a disabled placeholder before this reversal was decided — Phase D's remaining frontend work is wiring it to the real print flow instead of leaving it disabled, plus theMatrixPrintDocumentwork above.)
- Body: existing
EditorCanvas/DataProvider/BandProviderpreview for matrix; for A4, mountPrintDocument(confirmed stateless/reusable, PrintDocument.jsx) scaled into the modal body instead of only appending it todocument.bodyforwindow.print()— needs a scaled/contained rendering mode, not the full-bleed print-root mountingprintReport.jsxuses today. - Footer bar: centered Print + Close. Print triggers the kind-appropriate
existing print flow (
printA4Reportfor a4; for matrix, there is currently no equivalent "just print this" path —ReportPreviewModaltoday is preview-only with no print button at all, so a matrix print path needs to be added, likely also viawindow.print()on the rendered preview DOM, or reusing the same print-root-append patternprintReport.jsxestablished). Then calls the newmark-notice-printedendpoint. - Replace
Main.jsx's current branching logic (handlePrintNotice, lines 375-398) so BOTH kinds open this one modal (today only matrix does; a4 skips the modal entirely and goes straight towindow.print()) — this is the biggest behavioral change tohandlePrintNotice: it becomes "always open the modal," full stop, with the modal itself deciding what Print/Download do based on the loaded report's kind.
3. New History page — new route (e.g. /history) added alongside the
existing routes in
App.jsx (inside the same provider nest, following the
existing route-gating pattern used for admin-only pages). Fetches finalized
vehicles (?doc_number__isnull=false, newest first) independently of the
live vehicles array (History should NOT depend on NomenclatureContext's
in-memory list, since that list is specifically the active set now).
List each with vehicle number, doc number, net weight, date, and a
completed/voided badge (is_void); clicking a row opens the same unified
ReportPreviewModal, passing that vehicle's data exactly as Main.jsx does
today via vehicleReportData
(Main.jsx:313-334 — this
transform logic is reusable, likely worth extracting to a shared helper
rather than duplicating in the new History component). Print/Download PDF
stay available on a voided row too — voiding never removes the ability to
see or reproduce the original document, it only flags that the weighing
itself shouldn't be treated as valid.
- Void button: on a completed (non-voided) row, a distinct action from
opening the preview — requires a reason (matches the backend's required
reasonfield), then calls the new/void/endpoint. On success, the row updates in place to show the voided badge; it does not disappear or move anywhere (History is a flat list of everything that ever completed, voided or not). Confirm with a dialog before voiding — this is meant to be rare and deliberate, matching the "сторно/reversal" concept's own seriousness (it's correcting the record, not casually undoable further). - A nav entry for History (in
Header.jsx, alongside whatever nav already exists) — must be easy to find, per the whole point of this feature being "don't bury the recovery path."
Open questions to resolve during implementation
Phase D, resolved: no Playwright/Puppeteer, no server-side PDF endpoint (see
the Download PDF decision above). The matrix print path question is already
resolved too — Phase C built it (print-root-append, following
printReport.jsx's pattern, confirmed working).
Remaining open item, genuinely new scope surfaced while designing Phase D:
- Matrix has no
PrintDocument-equivalent. A4'sPrintDocument.jsx(print/PrintDocument.jsx) is a small (107-line), stateless, non-interactive component that readslayout()'s purepages[]output and renders plain positioned HTML — no hooks, no drag/select/delete callbacks. Its own doc comment already anticipated exactly this kind of reuse: "DELIBERATELY DUMB... that is what lets a future Puppeteer service consume this exact markup." Matrix has no equivalent: its preview goes throughEditorCanvas(EditorCanvas.jsx, 682 lines), the SAME interactive editor component used for actual editing, gated by apreviewModeprop — confirmed by reading it thatpreviewModeis a flag threaded through the interactive tree, not a separate rendering path; every element still gets fullonSelect/onUpdate/onDelete/onDragStartcallback props even in preview mode. Phase C's matrix print-root flow works today only because it reuses this sameEditorCanvas-in-previewModemachinery — it works, but it's borrowing an editor component for a job a pure renderer should do.- Decided: build a
MatrixPrintDocument-equivalent as part of this phase — a pure, non-interactive component mirroring whatPrintDocument.jsxalready does for A4, reading matrixelements+ resolved band/data output and emitting plain markup. This is real, bounded new work (not a redesign of anything existing), and it's a prerequisite for the matrix print/preview path to reach the same quality bar A4 already has — it does NOT unlock server-side PDF generation (that's still explicitly out of scope, per the reversal above); it's purely about correctness/simplicity of the existing client-side matrix print flow, replacing "borrow the editor" with "use a real preview renderer," same shape as A4.
- Decided: build a
Verification
- Create a vehicle without
driver_namevia the API directly (curl/Postman) — confirm it's now rejected with a 400 and the field-specific error, where today it would silently succeed withdriver_namedropped. - Weigh a vehicle twice (tare + gross) in the browser — confirm it still shows in the active list (finalization alone must not remove it).
- Click Print Notice — confirm the unified modal opens (for BOTH a matrix-
and an a4-kind
notice_report, test each), with top bar (kind switcher + Download PDF), body preview, footer (Print + Close centered). - Click Close without printing — confirm the vehicle is still in the active list (opening the modal alone must not archive).
- Click Print — confirm
window.print()/printA4Reportstill works exactly as before, AND the vehicle disappears from the active list within ~1s on the SAME tab and on a SECOND open tab (real-time SSE removal across stations). - Click Download PDF instead — confirm it opens the same
window.print()dialog as Print (with "Save as PDF" available as a destination, per the reversed decision — no direct blob download, no bypass of the OS dialog), and the vehicle is similarly removed from the active list once triggered. - Open History — confirm the just-archived vehicle appears, newest first, with correct doc_number/net/date.
- Click that History row — confirm the same modal opens and Print/Download both work again (the reprint path this whole feature exists to guarantee).
- Confirm a vehicle still mid-weighing (no
grossyet) never appears in History. - Reuse a plate: complete a weighing for
B7898TP, confirm it archives to History, then create a brand-new active vehicle also namedB7898TP— confirm this succeeds (no uniqueness error), proving the partial constraint is scoped correctly. Then try creating a SECOND simultaneous activeB7898TPwhile the first is still in progress — confirm this is rejected (proving uniqueness is still enforced among active records). - From History, void a completed record without providing a reason — confirm it's rejected. Void it with a reason — confirm the row updates in place with a voided badge, doc_number/net/tare/gross are unchanged, and Print/Download still work on it afterward.
- Attempt to void an active (non-completed) vehicle directly via the API — confirm it's rejected (void is completed-records-only, per scope).
- Confirm nothing in this feature ever issues a DELETE against a vehicle.
Done, not just checked:
VehicleViewSet.destroy()(the LIVE one inapi/views.py— found reachable viaIsAuthenticatedalone, worse than the orphanedvehicles/views.pythis item originally worried about) now unconditionally returns405pointing at/void/instead. Verify with a directDELETE /api/vehicles/{id}/call that it's rejected and the vehicle still exists afterward. - Matrix print quality: with the new
MatrixPrintDocument, print (and Download-as-PDF-via-print) a matrixnotice_report— confirm the output matches what the oldEditorCanvas-in-previewModeflow produced (no regression), and confirm the print/preview path no longer carries editor-only affordances (no residual selection/drag artifacts, since a non-interactive component can't have any).