item mng with sync
This commit is contained in:
@@ -1,52 +1,280 @@
|
||||
# 01 — Item Catalog
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 00 — Existing Gap Fixes
|
||||
**Status**: Ready
|
||||
**Depends On**: 00 — Existing Gap Fixes (gaps 1 & 2 done)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
|
||||
Define and implement a full item catalog with reference masters (Category, SubCategory, Brand, UOM)
|
||||
and the Item entity itself. Support both standalone clients (full CRUD) and ERP-integrated clients
|
||||
(sync endpoint that upserts from ERP). Everything downstream — stock ledger, orders, rides — reads
|
||||
from the local item table regardless of how items got there.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
|
||||
Two client types:
|
||||
- **Standalone** — client manages their own catalog. Creates categories, brands, UOMs, and items
|
||||
directly via CRUD APIs. `SyncSource = Manual`.
|
||||
- **ERP + Distribution** — ERP pushes data into Distribution via sync endpoints. CRUD write
|
||||
endpoints are blocked for synced records. `SyncSource = Erp`.
|
||||
|
||||
ERP uses `int` PKs. Distribution uses `Guid` PKs. Each reference master and item carries an
|
||||
`ExternalErpId` (nullable int) so the sync can match and upsert by ERP ID and resolve local Guids.
|
||||
|
||||
Sync order is strict — ERP must push in this order:
|
||||
1. UOMs
|
||||
2. Categories + SubCategories
|
||||
3. Brands
|
||||
4. Items (sync resolves local Guid FKs via ExternalErpId lookups)
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Item
|
||||
### Category
|
||||
```
|
||||
Category
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Name (string)
|
||||
├── ExternalErpId (int?) — null for manually created
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### SubCategory
|
||||
```
|
||||
SubCategory
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Name (string)
|
||||
├── FK_CategoryId (Guid) — required, parent Category
|
||||
├── ExternalErpId (int?)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
Rule: SubCategory must belong to the CategoryId it is assigned to. A subcategory cannot be
|
||||
moved to a different category after creation.
|
||||
|
||||
### Brand
|
||||
```
|
||||
Brand
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Name (string)
|
||||
├── ExternalErpId (int?)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### Related Changes to Existing Models
|
||||
### Uom (Unit of Measure)
|
||||
```
|
||||
Uom
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Name (string) — e.g. "PC", "CASE", "KG", "BOTTLE"
|
||||
├── ExternalErpId (int?)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### UomConversion
|
||||
```
|
||||
UomConversion
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── FK_ItemId (Guid) — which item this conversion belongs to
|
||||
├── FK_FromUomId (Guid) — source UOM
|
||||
├── FK_ToUomId (Guid) — target UOM
|
||||
├── Factor (decimal) — FromQty × Factor = ToQty
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
Example: Item = "Coca-Cola 330ml", FromUom = CASE, ToUom = BOTTLE, Factor = 24
|
||||
→ 1 CASE × 24 = 24 BOTTLES
|
||||
|
||||
### Item (replaces empty Item.cs)
|
||||
```
|
||||
Item
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Sku (string, unique) — field identifier, unique across all items
|
||||
├── Name (string)
|
||||
├── Description (string?)
|
||||
├── FK_CategoryId (Guid) — required
|
||||
├── FK_SubCategoryId (Guid?) — optional
|
||||
├── FK_BrandId (Guid?) — optional
|
||||
├── FK_BaseUomId (Guid) — required, base unit for stock counting
|
||||
├── SalePrice (decimal?) — NULL = price entered manually at order time
|
||||
├── SyncSource (enum) — Manual | Erp
|
||||
├── ExternalErpItemId (int?) — null for standalone items
|
||||
├── LastSyncedAt (DateTime?) — null for standalone items
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
### OrderItem — add UnitPrice
|
||||
```
|
||||
OrderItem (existing, add one column)
|
||||
├── UnitPrice (decimal) — price captured at time of order, not looked up live
|
||||
```
|
||||
This is a migration change on an existing table.
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
Two migrations needed:
|
||||
1. **AddItemCatalogReferenceMasters** — creates Category, SubCategory, Brand, Uom, UomConversion tables
|
||||
2. **UpdateItemAndOrderItem** — adds columns to existing Items table, adds UnitPrice to OrderItems
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Categories
|
||||
| Method | Route | Permission | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | /api/categories | categories.view | Returns all active, includes subcategory count |
|
||||
| GET | /api/categories/{id} | categories.view | Includes subcategories list |
|
||||
| POST | /api/categories | categories.create | Standalone only |
|
||||
| PUT | /api/categories/{id} | categories.update | Blocked if SyncSource = Erp |
|
||||
| DELETE | /api/categories/{id} | categories.delete | Soft delete, blocked if items reference it |
|
||||
|
||||
### SubCategories
|
||||
| Method | Route | Permission | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | /api/categories/{categoryId}/subcategories | categories.create | Validates parent exists |
|
||||
| PUT | /api/subcategories/{id} | categories.update | Blocked if SyncSource = Erp |
|
||||
| DELETE | /api/subcategories/{id} | categories.delete | Blocked if items reference it |
|
||||
|
||||
### Brands
|
||||
| Method | Route | Permission | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | /api/brands | items.view | |
|
||||
| POST | /api/brands | items.create | Standalone only |
|
||||
| PUT | /api/brands/{id} | items.update | Blocked if SyncSource = Erp |
|
||||
| DELETE | /api/brands/{id} | items.delete | Blocked if items reference it |
|
||||
|
||||
### UOMs
|
||||
| Method | Route | Permission | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | /api/uoms | items.view | |
|
||||
| POST | /api/uoms | items.create | Standalone only |
|
||||
| PUT | /api/uoms/{id} | items.update | Blocked if SyncSource = Erp |
|
||||
| DELETE | /api/uoms/{id} | items.delete | Blocked if items reference it |
|
||||
|
||||
### Items
|
||||
| Method | Route | Permission | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | /api/items | items.view | Filterable by category, brand, subcategory |
|
||||
| GET | /api/items/{id} | items.view | Includes UOM conversions |
|
||||
| POST | /api/items | items.create | Validates SKU unique, FK references exist |
|
||||
| PUT | /api/items/{id} | items.update | Blocked if SyncSource = Erp |
|
||||
| DELETE | /api/items/{id} | items.delete | Blocked if stock exists for item |
|
||||
| PUT | /api/items/{id}/uom-conversions | items.update | Replace full conversion list |
|
||||
|
||||
### Sync (ERP → Distribution)
|
||||
| Method | Route | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | /api/sync/uoms | JWT (service user) | Upsert by ExternalErpId |
|
||||
| POST | /api/sync/categories | JWT (service user) | Upsert by ExternalErpId, includes subcategories |
|
||||
| POST | /api/sync/brands | JWT (service user) | Upsert by ExternalErpId |
|
||||
| POST | /api/sync/items | JWT (service user) | Upsert by ExternalErpItemId, resolves local FK Guids via ExternalErpId |
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
1. SKU must be unique across all items.
|
||||
2. SubCategory must belong to the specified CategoryId — cannot assign a subcategory from a
|
||||
different parent category.
|
||||
3. Items with `SyncSource = Erp` cannot be modified via standard CRUD endpoints (PUT/DELETE).
|
||||
Only the sync endpoint can update them.
|
||||
4. Reference masters (Category, Brand, UOM) with `SyncSource = Erp` cannot be modified via
|
||||
standard CRUD endpoints.
|
||||
5. A Category cannot be deleted if any SubCategory or Item references it.
|
||||
6. A SubCategory cannot be deleted if any Item references it.
|
||||
7. A Brand cannot be deleted if any Item references it.
|
||||
8. A UOM cannot be deleted if any Item uses it as BaseUom or if any UomConversion references it.
|
||||
9. An Item cannot be deleted (soft delete blocked) if stock exists for it. Hard delete never
|
||||
allowed.
|
||||
10. `UnitPrice` on OrderItem is captured at the moment the order is created from `Item.SalePrice`.
|
||||
If `SalePrice` is null, the caller must supply the price explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Permissions to Add to Seed
|
||||
|
||||
New permission codes to add to the existing seed:
|
||||
```
|
||||
items.view, items.create, items.update, items.delete
|
||||
categories.view, categories.create, categories.update, categories.delete
|
||||
```
|
||||
|
||||
Role assignments:
|
||||
- **SalesRep (10)**: items.view, categories.view
|
||||
- **Supervisor (20)**: items.view, categories.view
|
||||
- **AreaManager (30)**: items.view, categories.view, items.create, items.update
|
||||
- **AreaSupervisor (40)**: all item + category permissions
|
||||
- **RegionalManager (50)**: all item + category permissions
|
||||
- **RegionalAgency (60)**: all item + category permissions
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
- None. Resolved in planning discussion.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
### File structure to create
|
||||
```
|
||||
Features/
|
||||
Category/
|
||||
CategoryController.cs
|
||||
CategoryDTOs.cs
|
||||
CategoryService.cs
|
||||
ICategoryRepository.cs
|
||||
CategoryRepository.cs
|
||||
Brand/
|
||||
BrandController.cs
|
||||
BrandDTOs.cs
|
||||
BrandService.cs
|
||||
IBrandRepository.cs
|
||||
BrandRepository.cs
|
||||
Uom/
|
||||
UomController.cs
|
||||
UomDTOs.cs
|
||||
UomService.cs
|
||||
IUomRepository.cs
|
||||
UomRepository.cs
|
||||
Item/
|
||||
ItemController.cs
|
||||
ItemDTOs.cs
|
||||
ItemService.cs
|
||||
IItemRepository.cs
|
||||
ItemRepository.cs
|
||||
Sync/
|
||||
SyncController.cs
|
||||
SyncDTOs.cs
|
||||
SyncService.cs
|
||||
|
||||
Models/
|
||||
Category.cs (new)
|
||||
SubCategory.cs (new)
|
||||
Brand.cs (new)
|
||||
Uom.cs (new)
|
||||
UomConversion.cs (new)
|
||||
Item.cs (replace empty)
|
||||
|
||||
Migrations/
|
||||
AddItemCatalogReferenceMasters
|
||||
UpdateItemAndOrderItem
|
||||
```
|
||||
|
||||
### Sync upsert logic (pseudo)
|
||||
```
|
||||
POST /api/sync/items — for each item in payload:
|
||||
1. Lookup local Category by ExternalErpId = item.categoryId → get local CategoryGuid
|
||||
2. Lookup local SubCategory by ExternalErpId = item.subCategoryId (if present)
|
||||
3. Lookup local Brand by ExternalErpId = item.brandId (if present)
|
||||
4. Lookup local Uom by ExternalErpId = item.baseUomId → get local UomGuid
|
||||
5. Find existing Item where ExternalErpItemId = item.itemId
|
||||
→ if found: update fields, set LastSyncedAt = now
|
||||
→ if not found: insert new, SyncSource = Erp
|
||||
```
|
||||
|
||||
@@ -35,7 +35,7 @@ They must be resolved before new feature steps begin because later steps depend
|
||||
| # | Feature | Status | Depends On | Detail File |
|
||||
|---|---|---|---|---|
|
||||
| 00 | Existing Gap Fixes | Not Started | — | [00_existing_gaps.md](./00_existing_gaps.md) |
|
||||
| 01 | Item Catalog | Not Started | 00 | [01_item_catalog.md](./01_item_catalog.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) |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user