Damaged Items
Status: ✅ Implemented (MVP Phase 1)
Branch: damage-items
What Is This Feature
Damaged Items is the inventory workflow that lets a business formally record and track stock that has been damaged, spoiled, or needs to be quarantined for inspection. It is built around a Damage Report document — analogous to a Purchase Order or Sales Order — with a header and line items, and a lifecycle of draft → posted.
When a report is posted, the system atomically:
- Validates that on-hand stock is sufficient for each line
- Moves qty from the
on_handbucket →damagedorquarantinebucket on theinventoryaggregate row - Appends an immutable row to
inventory_ledgerfor each line (full audit trail) - Marks the report as
posted
Business Concepts
| Concept | Meaning |
|---|---|
| Damage Report | The document. Created as a draft; must be explicitly posted to affect stock |
| Damage Reason | A business-specific catalog entry explaining why items were damaged (e.g. "Breakage", "Expired", "Flood") |
| Damaged bucket | The damaged column on inventory — items that are written off or awaiting disposal |
| Quarantine bucket | The quarantined column on inventory — items held for inspection; may be restored to on-hand later |
| Posting | The act of committing a draft report, triggering stock movements and ledger entries |
Document Lifecycle
DRAFT ──► POSTED
│
▼
(REVERSED ← Phase 2)
Only DRAFT reports can be edited or deleted. Once posted, the record is immutable (reversal is a Phase 2 feature).
Architecture
Why a separate damage_report_line table (not JSON)?
Each line maps 1:1 to an inventory_ledger row via sourceLineId. This creates a direct, queryable audit link from any ledger movement back to the exact line that caused it — which is not possible with a JSON blob. Additionally, product_id and location_id FKs on lines are enforced by the database.
See the Design Decisions section for the full rationale.
Database Schema
New Tables
damage_reason
Business-specific catalog of damage/loss reasons.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
business_id | uuid FK | Scoped per business |
name | varchar(255) | Required |
description | text | Optional |
is_active | boolean | Soft-disable without deleting |
created_at / updated_at | timestamptz | |
created_by / updated_by | uuid FK → user |
damage_report
The document header.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
business_id | uuid FK | |
location_id | uuid FK | Location where damage occurred |
location_name | varchar | Denormalized for display |
status | damage_report_status | draft, posted, reversed |
document_number | varchar(100) | Auto-assigned on post (Phase 2) |
notes | text | Optional |
occurred_at | timestamptz | When the damage occurred (user-supplied) |
created_at / updated_at | timestamptz | |
created_by / updated_by | uuid FK → user | |
posted_at / posted_by | timestamptz / uuid | Set on post |
reversed_at / reversed_by | timestamptz / uuid | Set on reversal (Phase 2) |
reference_type / reference_id | varchar / uuid | Optional link to source document |
damage_report_line
One row per product in the report.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | Used as sourceLineId in inventory_ledger |
damage_report_id | uuid FK | Parent document |
business_id / location_id | uuid FK | Denormalized for ledger |
product_id | uuid FK | |
product_name | varchar | Denormalized for display |
damage_reason_id | uuid FK → damage_reason | Optional |
target_bucket | varchar(50) | damaged or quarantine |
qty | numeric(20,6) | Quantity moved |
unit_cost / total_cost | numeric(20,6) | Optional cost capture |
batch_number / serial_number | varchar | Optional lot/serial tracking |
notes | text | Per-line note |
created_at / created_by | timestamptz / uuid |
Extended Enums
movement_type (existing) — new values added:
damage_mark— on_hand → damageddamage_restore— damaged → on_hand (Phase 2)quarantine_mark— on_hand → quarantinequarantine_release— quarantine → on_hand (Phase 2)
damage_report_status (new):
draft|posted|reversed
Existing Columns Used
The inventory table already had these columns — no schema change required:
| Column | What it tracks |
|---|---|
damaged / damaged_cost | Qty and cost in the damaged bucket |
quarantined / quarantined_cost | Qty and cost in the quarantine bucket |
Backend Modules
damage-reasons module
Path: apps/backend/src/damage-reasons/
Base URL: /damage-reasons
Simple CRUD for the reason catalog.
| Method | Endpoint | Description |
|---|---|---|
POST | /damage-reasons | Create a reason |
GET | /damage-reasons?businessId=&q=&includeInactive= | Paginated list |
GET | /damage-reasons/:id | Single reason |
PATCH | /damage-reasons/:id | Update (name, description, isActive) |
DELETE | /damage-reasons/:id | Delete |
damage-reports module
Path: apps/backend/src/damage-reports/
Base URL: /damage-reports
| Method | Endpoint | Description |
|---|---|---|
POST | /damage-reports | Create draft report with lines |
GET | /damage-reports?businessId=&locationId=&status=&q= | Paginated list |
GET | /damage-reports/:id | Detail with lines |
PATCH | /damage-reports/:id/post | Post the report (commits stock movements) |
DELETE | /damage-reports/:id | Delete (draft only) |
Posting logic (single transaction)
for each line:
1. SELECT inventory WHERE locationId AND productId → validate qty >= line.qty
2. UPDATE inventory SET quantity -= line.qty → decrease on_hand
3. UPDATE inventory SET damaged/quarantined += line.qty → increase target bucket
4. INSERT inventory_ledger (movement_type, stock_bucket, sourceLineId, ...)
UPDATE damage_report SET status = 'posted', posted_at, posted_by
If any line fails the quantity check, the entire transaction rolls back.
Inventory Repository Methods
Two new methods were added to InventoriesRepository:
// Move qty INTO the damaged or quarantine bucket
increaseDamagedBucket({ locationId, productId, qty, cost, targetBucket, userId, transaction })
// Move qty OUT OF the damaged or quarantine bucket (used for restore — Phase 2)
decreaseDamagedBucket({ locationId, productId, qty, cost, targetBucket, userId, transaction })
targetBucket: "damaged" | "quarantine" controls which pair of columns is updated.
Frontend (PWA)
Route: /damaged-items
Files
| File | Purpose |
|---|---|
src/types/damagedItems.ts | All TypeScript interfaces and enums |
src/services/damagedItemsService.ts | API calls |
src/components/forms/damaged-items/DamagedItemsPage.tsx | Root page — tabs: Reports / Damage Reasons |
src/components/forms/damaged-items/DamageReportList.tsx | Paginated list of reports |
src/components/forms/damaged-items/DamageReportForm.tsx | Create draft + optionally post immediately |
src/components/forms/damaged-items/DamageReasonsAdmin.tsx | CRUD for the reason catalog |
UI Flow
DamagedItemsPage
├── Tab: Reports
│ ├── DamageReportList (lists existing reports)
│ └── [+ New Report] → modal
│ └── DamageReportForm
│ ├── [Save Draft] → POST /damage-reports
│ └── [Save & Post] → POST /damage-reports + PATCH /:id/post
└── Tab: Damage Reasons
└── DamageReasonsAdmin (CRUD)
Tapping a report in the list opens a detail modal showing lines and a Post Report button (if still draft).
Global Enums
packages/global/enums/damage-report.enums.ts
export enum DamageReportStatus {
DRAFT = "draft",
POSTED = "posted",
REVERSED = "reversed",
}
export enum DamageTargetBucket {
DAMAGED = "damaged",
QUARANTINE = "quarantine",
}
Design Decisions
Separate lines table vs JSON
A damage_report_line table was chosen over a JSON column on damage_report because:
- Ledger attribution — Each
inventory_ledgerrow referencessourceLineId. With JSON there is no stable UUID per line to reference. - FK integrity —
product_idandlocation_idFKs are enforced at the DB level. - Product queries — "Which reports included product X?" is an indexed lookup on a proper table; it would require
jsonb @>or a full scan on JSON.
Draft → Post separation
The two-step workflow (create draft, then post separately) was chosen over a single "post immediately" API because:
- It supports the review-before-commit workflow (supervisor reviews before posting).
- The frontend also offers a convenience "Save & Post" button that calls both endpoints sequentially for users who don't need review.
Why not reuse inventory_adjustments?
inventory_adjustments moves qty within the on_hand bucket (increase/decrease). Damage reports move qty between buckets (on_hand → damaged/quarantine), which requires updating two separate column pairs on inventory. This is a fundamentally different operation that warrants its own module.
Phase 2 Roadmap
- Restore flow —
PATCH /damage-reports/:id/restore-line/:lineId— moves qty back from damaged/quarantine → on_hand - Reversal — full report reversal (reverse all lines in one transaction)
- Document numbering — auto-assign
document_numberon post using the existinggenerateDocumentNumberutility - Reports & KPIs — damaged stock value by location, most-damaged products, damage by reason
- Cost defaulting — auto-fill
unit_costfrom the inventory's current average cost on line entry - Barcode scanning — reuse
useScannerhook from stock-count for fast product entry inDamageReportForm
Setup Checklist (after merging)
# 1. Apply the migration to your local DB
pnpm run migration:local:push
# 2. Regenerate Kysely TypeScript types
# This replaces the `as any` pre-migration casts in the repositories
pnpm run generate:types
# 3. Restart the backend
pnpm --filter backend run start:dev
Note: Until
generate:typesis run,damage_reason,damage_report, anddamage_report_lineare not in the generatedDBtype. The repositories usebiome-ignorecasts as a temporary bridge. After regeneration those casts can be removed and thedamage-reason.types.ts/damage-report.types.tsplaceholder files should be updated to import fromdatabase.types.