Two things go wrong at goods-in and they are not the same thing. A shipment arrives with fewer pallets than the paperwork declares, or a different weight, or cartons that don't reconcile against the manifest — that is a discrepancy against a document, and it has to be raised while the freight and the driver are both still there. Separately, material gets damaged inside the building, which is on nobody's paperwork and is still somebody's cost.
Both end in the same place: a decision about who pays, taken weeks later in an email thread, by people looking at whatever documentation exists. That documentation is dated photographs, the bill of lading, the tracking number, and a description written while the pallet was still in front of the person writing it. A system that captures the description but loses the photographs is a system that loses the argument.
- Declared and actual counts have to sit side by side — pallets, cartons and weight
- The evidence is photographs, photographs are large, and the only moment to take them is now
- An exception noticed at the dock did not automatically become work assigned to anyone
Three operator screens and two case records. A receiving exception log for what actually arrived against what was declared; a damage log for material damaged inside the building; and a case manager where cases live out their lives. The receiving log and the case manager share one case record. The damage log keeps its own, with its own attachment store and its own generated report — the two kinds of claim are settled by different people, and forcing them into one table would have bought nothing.
The link between the receiving log and the case manager is the part that changed the working day. Of the nine exception types the receiving screen offers, seven automatically open a case — carrying the affected units across, writing a system comment that names each triggering exception with its licence plate, part and serial, and bringing the photographs with it. The other two are recorded and stop there, because not every note needs to become somebody's assignment.
- Nine lifecycle states, including three distinct ways for a case to be closed
- New cases assign themselves round-robin to whoever has gone longest without one; anyone who has never had one goes first
- Access held only through a leadership role does not put you in the rotation
- Saving an existing case never quietly reassigns it to whoever pressed save
- A case left in its opening state for 48 hours moves itself into a late state, on a check that is throttled to once every five minutes rather than run on every page load
- Before a new case is created, the screen looks for an open case on the same reference and offers to add units to that one instead
- Every edit to a tracked field — eleven of them — is written to a change log with the old value, the new value, the user and the time
Getting the photographs to the server
The unglamorous obstacle on this build was upload size. Photographs from a current phone are large, the web server in front of the application rejects oversized request bodies, and it rejects them in two different ways — a plain 413, and a 404 subcode rendered as an HTML error page. A client that only reads the status code sees the second one as a missing route and tells the user something untrue. Both screens that upload evidence check for both.
On the damage screen the upload path is deliberate rather than convenient. Files go up one at a time as multipart form data, never as base64 inside a JSON body, which is where the size problem starts. Images are downscaled through a ladder of six width-and- quality steps until they fit under a safe ceiling, keeping whichever attempt came out smallest. Each file gets up to three attempts, the target tightened after each rejection. A file that still refuses to go produces a named warning against that filename — and the case is saved anyway, because losing the whole record over one stubborn photo is the worse failure. The case manager compresses and posts multipart too, but batches its files into one request; it inherited the rejection handling, not the ladder.
- The notification is sent after the attachments finish uploading, not at case creation, so the generated report actually contains the photos
- Attachment category is decided from the content type rather than from the operator remembering to choose one, and only images are embedded in the report
- The PDF scales each photograph to the page; an image that cannot be decoded prints a line saying so instead of failing the whole document
- A mail failure is caught and logged, never allowed to fail a case that has already been written
- An attachment uploaded against a case that doesn't exist has its file removed from disk again rather than left orphaned
- The grid carries an attachment count, never the files — photographs are fetched only when a case is opened, which is what keeps the list usable
- In-progress case work mirrors to browser storage to survive a discarded background tab, in two independent draft slots armed only by a real user edit; uploaded photos and documents are excluded on purpose, because base64 would exhaust the storage quota, and a restore that had to drop pasted comment images says so
Closing a case without losing the freight
Three statuses close a case. Only two of them take the case's units out of tracked inventory. The third finishes the case for lifecycle purposes while the material is still physically in the building — and conflating those two facts is exactly how inventory quietly disappears from a system.
Closing captures the units before deactivating them, then writes one shipped transaction per unit, so a closed case's material stays findable in history instead of vanishing. Reopening is the harder direction. It restores at most one row per licence plate, and never on top of a plate that already has an active row, because a blanket reactivation there used to resurrect duplicate inventory.
- The close timestamp and closing user are set once, on the transition, and not rewritten by every later save
- Case numbers come from a counter row updated under a lock rather than from max-plus-one, so two people creating cases in the same second cannot collide
- A unit quantity above a sane ceiling is rejected on write — it is a scanned barcode that landed in the quantity field, and storing one corrupts every total that sums it
- A search term the server doesn't recognise is treated as a search term, never silently dropped; a dropped filter turns a search into a dump of every case, which is worse than an error
Every case now ends in a document. The generated report carries the case detail, a manifest of every attachment with its size, uploader and upload time, and each photograph rendered in place. It is emailed once the attachments are up, and a dedicated endpoint rebuilds the report from the case's current attachments and sends it again on demand — which matters when the argument starts three months later.
The dock also has a path that doesn't depend on the server's mailbox. The receiving screen assembles a complete MIME message in the browser — multipart/mixed around a multipart/alternative body, photographs and bills of lading as real attachments, base64 wrapped at the line length the standard asks for — and hands it over as a message file the operator's own mail client opens, with the recipient left blank. It goes out from the person who saw the damage, to whoever that particular situation needs, rather than to whatever single address the server-side notification was configured with.