Saltar al contenido principal

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:

  1. Validates that on-hand stock is sufficient for each line
  2. Moves qty from the on_hand bucket → damaged or quarantine bucket on the inventory aggregate row
  3. Appends an immutable row to inventory_ledger for each line (full audit trail)
  4. Marks the report as posted

Business Concepts

ConceptMeaning
Damage ReportThe document. Created as a draft; must be explicitly posted to affect stock
Damage ReasonA business-specific catalog entry explaining why items were damaged (e.g. "Breakage", "Expired", "Flood")
Damaged bucketThe damaged column on inventory — items that are written off or awaiting disposal
Quarantine bucketThe quarantined column on inventory — items held for inspection; may be restored to on-hand later
PostingThe 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.

ColumnTypeNotes
iduuid PK
business_iduuid FKScoped per business
namevarchar(255)Required
descriptiontextOptional
is_activebooleanSoft-disable without deleting
created_at / updated_attimestamptz
created_by / updated_byuuid FK → user

damage_report

The document header.

ColumnTypeNotes
iduuid PK
business_iduuid FK
location_iduuid FKLocation where damage occurred
location_namevarcharDenormalized for display
statusdamage_report_statusdraft, posted, reversed
document_numbervarchar(100)Auto-assigned on post (Phase 2)
notestextOptional
occurred_attimestamptzWhen the damage occurred (user-supplied)
created_at / updated_attimestamptz
created_by / updated_byuuid FK → user
posted_at / posted_bytimestamptz / uuidSet on post
reversed_at / reversed_bytimestamptz / uuidSet on reversal (Phase 2)
reference_type / reference_idvarchar / uuidOptional link to source document

damage_report_line

One row per product in the report.

ColumnTypeNotes
iduuid PKUsed as sourceLineId in inventory_ledger
damage_report_iduuid FKParent document
business_id / location_iduuid FKDenormalized for ledger
product_iduuid FK
product_namevarcharDenormalized for display
damage_reason_iduuid FK → damage_reasonOptional
target_bucketvarchar(50)damaged or quarantine
qtynumeric(20,6)Quantity moved
unit_cost / total_costnumeric(20,6)Optional cost capture
batch_number / serial_numbervarcharOptional lot/serial tracking
notestextPer-line note
created_at / created_bytimestamptz / uuid

Extended Enums

movement_type (existing) — new values added:

  • damage_mark — on_hand → damaged
  • damage_restore — damaged → on_hand (Phase 2)
  • quarantine_mark — on_hand → quarantine
  • quarantine_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:

ColumnWhat it tracks
damaged / damaged_costQty and cost in the damaged bucket
quarantined / quarantined_costQty 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.

MethodEndpointDescription
POST/damage-reasonsCreate a reason
GET/damage-reasons?businessId=&q=&includeInactive=Paginated list
GET/damage-reasons/:idSingle reason
PATCH/damage-reasons/:idUpdate (name, description, isActive)
DELETE/damage-reasons/:idDelete

damage-reports module

Path: apps/backend/src/damage-reports/ Base URL: /damage-reports

MethodEndpointDescription
POST/damage-reportsCreate draft report with lines
GET/damage-reports?businessId=&locationId=&status=&q=Paginated list
GET/damage-reports/:idDetail with lines
PATCH/damage-reports/:id/postPost the report (commits stock movements)
DELETE/damage-reports/:idDelete (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

FilePurpose
src/types/damagedItems.tsAll TypeScript interfaces and enums
src/services/damagedItemsService.tsAPI calls
src/components/forms/damaged-items/DamagedItemsPage.tsxRoot page — tabs: Reports / Damage Reasons
src/components/forms/damaged-items/DamageReportList.tsxPaginated list of reports
src/components/forms/damaged-items/DamageReportForm.tsxCreate draft + optionally post immediately
src/components/forms/damaged-items/DamageReasonsAdmin.tsxCRUD 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:

  1. Ledger attribution — Each inventory_ledger row references sourceLineId. With JSON there is no stable UUID per line to reference.
  2. FK integrityproduct_id and location_id FKs are enforced at the DB level.
  3. 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_handdamaged/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 flowPATCH /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_number on post using the existing generateDocumentNumber utility
  • Reports & KPIs — damaged stock value by location, most-damaged products, damage by reason
  • Cost defaulting — auto-fill unit_cost from the inventory's current average cost on line entry
  • Barcode scanning — reuse useScanner hook from stock-count for fast product entry in DamageReportForm

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:types is run, damage_reason, damage_report, and damage_report_line are not in the generated DB type. The repositories use biome-ignore casts as a temporary bridge. After regeneration those casts can be removed and the damage-reason.types.ts / damage-report.types.ts placeholder files should be updated to import from database.types.