stock_ledger completed need to test

This commit is contained in:
Kalana B. Thilakarathna
2026-08-20 09:42:33 +05:30
parent aa7ea37ab6
commit f95fda3806
37 changed files with 1976 additions and 5805 deletions
+210 -14
View File
@@ -1,62 +1,258 @@
# 02 — Stock Ledger
**Status**: Not Started
**Status**: Done
**Depends On**: 01 — Item Catalog
---
## Goal
Introduce stock tracking per location. Every warehouse and vehicle holds a quantity of each item.
Stock is never edited directly — all changes happen through movement transactions.
The current balance is always the materialized sum kept in sync with the transaction log.
---
## Background / Context
Two tables work together:
- **StockBalance** — one row per (ItemId, LocationId). Updated atomically on every movement. Used for fast reads and enforcing stock limits (e.g. driver can't sell more than loaded).
- **StockMovement** — append-only transaction log. Source of truth. Used for audit, reconciliation at ride close (Step 07), and re-deriving balance if drift is ever suspected.
Both are updated in the same UoW commit. They must always agree.
**Location abstraction:** A single `LocationId` (+ `LocationType` enum) covers both warehouses and vehicles. Same query pattern regardless of location type.
---
## Data Model Changes
### Stock Balance (per location)
### New Enum — LocationType
```csharp
public enum LocationType { Warehouse, Vehicle }
```
### New Enum — MovementType
### Stock Movement Transaction
```csharp
public enum MovementType
{
Sale,
CustomerReturn,
Transfer, // covers: warehouse→vehicle (load), vehicle→warehouse (driver return), warehouse→warehouse
Damage,
Adjustment
}
```
### New Entity — StockBalance
One row per (ItemId, LocationId). Updated on every movement.
### Location Abstraction (Warehouse vs Vehicle)
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_ItemId | Guid | FK → Item |
| LocationId | Guid | FK → Warehouse or Vehicle |
| LocationType | LocationType | Warehouse or Vehicle |
| Quantity | decimal | Current balance. Never goes below 0 |
| UpdatedAt | DateTime | Last movement time |
No `IsActive` / no soft delete — balance rows are permanent once created.
Composite unique index on `(FK_ItemId, LocationId)`.
### New Entity — StockMovement
Append-only. Never updated or deleted.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_ItemId | Guid | FK → Item |
| FromLocationId | Guid? | Null for Sale, Damage, Adjustment (single-location) |
| FromLocationType | LocationType? | Null when FromLocationId is null |
| ToLocationId | Guid? | Null for Sale, Damage (stock leaves system) |
| ToLocationType | LocationType? | Null when ToLocationId is null |
| Quantity | decimal | Always positive — direction implied by movement type |
| Type | MovementType | |
| ReasonCode | string? | Required for Adjustment and Damage |
| ReferenceId | Guid? | Links to the originating business event (OrderId, RideId, etc.) |
| CreatedAt | DateTime | |
| CreatedBy | Guid | UserId of who triggered this |
---
## Category Configuration
`Category` gets one new flag to support configurable customer returns:
| Column | Type | Notes |
|---|---|---|
| AllowCustomerReturn | bool | Default false. If false, CustomerReturn movements are rejected for items in this category |
---
## API Endpoints
Base route: `/api/stock`
### Query
| Method | Route | Description |
|---|---|---|
| GET | `/balances` | Get all stock balances (paginated). Filter by `locationId`, `locationType`, `itemId` |
| GET | `/balances/{itemId}/total` | Total stock of one item across all locations |
| GET | `/movements` | Get movement history (paginated). Filter by `locationId`, `itemId`, `type`, `from`, `to` |
### Write
| Method | Route | Description |
|---|---|---|
| POST | `/transfer` | Move stock between two locations |
| POST | `/damage` | Record damaged stock at a location |
| POST | `/customer-return` | Record a customer return to a vehicle |
| POST | `/adjustment` | Manual correction at a location (requires reason code) |
---
## Request / Response Shapes
### GET `/balances` response (paginated)
```json
{
"page": 1,
"pageSize": 20,
"total": 85,
"items": [
{
"itemId": "...",
"itemName": "Sprite 330ml",
"locationId": "...",
"locationType": "Warehouse",
"quantity": 120,
"updatedAt": "2026-08-19T10:00:00"
}
]
}
```
### GET `/balances/{itemId}/total` response
```json
{
"itemId": "...",
"itemName": "Sprite 330ml",
"totalQuantity": 340,
"breakdown": [
{ "locationId": "...", "locationType": "Warehouse", "quantity": 200 },
{ "locationId": "...", "locationType": "Vehicle", "quantity": 140 }
]
}
```
### GET `/movements` response (paginated)
```json
{
"page": 1,
"pageSize": 20,
"total": 412,
"items": [
{
"id": "...",
"itemId": "...",
"itemName": "Sprite 330ml",
"fromLocationId": "...",
"fromLocationType": "Warehouse",
"toLocationId": "...",
"toLocationType": "Vehicle",
"quantity": 50,
"type": "Transfer",
"reasonCode": null,
"referenceId": "...",
"createdAt": "2026-08-19T08:00:00",
"createdBy": "..."
}
]
}
```
### POST `/transfer` request
```json
{
"itemId": "...",
"fromLocationId": "...",
"fromLocationType": "Warehouse",
"toLocationId": "...",
"toLocationType": "Vehicle",
"quantity": 50,
"referenceId": "..."
}
```
### POST `/damage` request
```json
{
"itemId": "...",
"locationId": "...",
"locationType": "Warehouse",
"quantity": 3,
"reasonCode": "Broken during handling"
}
```
### POST `/customer-return` request
```json
{
"itemId": "...",
"vehicleId": "...",
"quantity": 2,
"referenceId": "..."
}
```
### POST `/adjustment` request
```json
{
"itemId": "...",
"locationId": "...",
"locationType": "Warehouse",
"quantity": -2,
"reasonCode": "Counting error corrected after audit"
}
```
---
## Business Rules
---
## Fraud / Control Considerations
1. `StockBalance.Quantity` must never go below 0. Any movement that would result in negative stock is rejected with a 400.
2. `Adjustment` and `Damage` require a non-empty `ReasonCode`.
3. `CustomerReturn` is rejected if `Category.AllowCustomerReturn = false` for the item's category.
4. `Transfer` debits `FromLocation` and credits `ToLocation` atomically.
5. Both `StockBalance` update and `StockMovement` insert happen in the same transaction — never one without the other.
6. `StockMovement` rows are never updated or deleted. Wrong entries are corrected by a counter-movement.
7. All list endpoints return paginated responses.
---
## Open Questions
None — all design decisions resolved in planning discussion.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
**Feature folder:** `Features/Stock/`
**Entities:** `StockBalance.cs`, `StockMovement.cs` in `Models/`
**Migration:** Add after entities and DbContext are updated
**Service layer:** One `StockService` handles all movement types. Each write method: begins transaction → validates stock → inserts movement → updates balance → commits.
**Category change:** Add `AllowCustomerReturn` column to existing `Category` entity and migration.
+1 -1
View File
@@ -36,7 +36,7 @@ They must be resolved before new feature steps begin because later steps depend
|---|---|---|---|---|
| 00 | Existing Gap Fixes | Done | — | [00_existing_gaps.md](./00_existing_gaps.md) |
| 01 | Item Catalog | Done | 00 | [01_item_catalog.md](./01_item_catalog.md) |
| 02 | Stock Ledger | Not Started | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
| 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
| 03 | Ride Lifecycle | Not Started | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
| 04 | Loading Event | Not Started | 03 | [04_loading_event.md](./04_loading_event.md) |
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |