Update procurement docs
This commit is contained in:
@@ -122,6 +122,15 @@ GoodsReceiptItem
|
|||||||
```
|
```
|
||||||
|
|
||||||
### PurchaseReturn
|
### PurchaseReturn
|
||||||
|
|
||||||
|
> **Not the same as `SaleReturn`** ([18_returns_claims.md](./18_returns_claims.md)), despite the
|
||||||
|
> similar name. `PurchaseReturn` moves goods **Distributor → Supplier** (debits the receiving
|
||||||
|
> warehouse); `SaleReturn` moves goods **Shop → Distributor** (credits a delivery vehicle). Also
|
||||||
|
> distinct from `SupplierClaim` ([18_returns_claims.md](./18_returns_claims.md)), which is for
|
||||||
|
> supplier disputes that don't involve physically returning goods (short-shipment, price dispute)
|
||||||
|
> and — unlike this entity — requires approval. See that doc's "Four distinct entities" table for
|
||||||
|
> the full comparison.
|
||||||
|
|
||||||
```
|
```
|
||||||
PurchaseReturn
|
PurchaseReturn
|
||||||
├── Id (Guid) — PK, from Template
|
├── Id (Guid) — PK, from Template
|
||||||
|
|||||||
@@ -1,74 +1,469 @@
|
|||||||
# 18 — Returns & Claims Management
|
# 18 — Returns & Claims Management
|
||||||
|
|
||||||
**Status**: Not Started
|
**Status**: Done — migrations applied to the database. Inspection/classification for customer
|
||||||
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution
|
returns is deferred (see Open Questions).
|
||||||
|
**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for
|
||||||
|
`SupplierClaim`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in
|
Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where
|
||||||
full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md)
|
a sales rep records a return during a store visit and a claim workflow against a Regional Agency,
|
||||||
both reference this step's credit note and approval shapes — this is where those are actually
|
plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve
|
||||||
defined.
|
physically returning goods.
|
||||||
|
|
||||||
|
### Four distinct entities — don't conflate them
|
||||||
|
|
||||||
|
`SaleReturn` and `PurchaseReturn` are both named "...Return" but move goods in opposite
|
||||||
|
directions, and neither is the same as the two `Claim` entities, which never move stock at all:
|
||||||
|
|
||||||
|
| Entity | Direction | Where it lives | Approval? | Stock effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SaleReturn` | Shop → Distributor | This step (`Features/Returns`) | No — settles immediately | Credits the ride's **vehicle** |
|
||||||
|
| `Claim` | vs. Regional Agency | This step (`Features/Claims`) | Settle only (`claims.settle`) | None |
|
||||||
|
| `PurchaseReturn` | Distributor → Supplier | [15_procurement.md](./15_procurement.md) (`Features/PurchaseReturn`) | No — settles immediately | Debits the receiving **warehouse** |
|
||||||
|
| `SupplierClaim` | vs. Supplier | This step (`Features/SupplierClaims`) | Yes (`suppliers.claims.approve`) | None — financial/administrative record only |
|
||||||
|
|
||||||
|
`PurchaseReturn` is for physically shipping goods back to a supplier; `SupplierClaim` is for
|
||||||
|
disputes that don't (or can't yet) involve moving goods back — a short-shipment, damage, or a
|
||||||
|
price dispute.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background / Context
|
## Background / Context
|
||||||
|
|
||||||
No return, credit note, or claim entity exists today. `createBackorder` (§1,
|
No return, credit note, or claim entity existed before this step. `createBackorder` (§1,
|
||||||
[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands.
|
[05_delivery_ride.md](./05_delivery_ride.md)) needed a credit note target once this step landed.
|
||||||
|
|
||||||
|
The original spec (mainPlan §12) framed customer returns as an async, supervisor-approved
|
||||||
|
workflow — `createReturnRequest` → `approveReturn` → stock credited only on approval. **That model
|
||||||
|
was dropped during planning.** In practice a sales rep records a return on the spot, during the
|
||||||
|
same store visit as a new order, and it must settle immediately (stock back in the vehicle, the
|
||||||
|
new order's amount adjusted) — there is no point in the field workflow where a return sits
|
||||||
|
"pending" a remote supervisor's sign-off. See Business Rules for what this changes.
|
||||||
|
|
||||||
|
The existing `PurchaseReturn` ([15_procurement.md](./15_procurement.md)) only covers physically
|
||||||
|
returning received goods to a supplier and debiting stock — it has no place for a claim that
|
||||||
|
doesn't (or can't yet) involve moving goods back: a short-shipment (500 ordered, 480 received),
|
||||||
|
goods damaged in transit, or a price dispute. `SupplierClaim` was added alongside `PurchaseReturn`
|
||||||
|
for this, not as a rework of it.
|
||||||
|
|
||||||
|
### Connections to existing modules
|
||||||
|
|
||||||
|
- **Order Management**: a `SaleReturn` is always filed against a specific open `Order`
|
||||||
|
(`Features/Order`) and immediately nets its effect onto that order's `TotalAmount` /
|
||||||
|
`OrderItem.Quantity` — no `Invoice` entity exists yet (Finance, step 19, not built), so there is
|
||||||
|
nothing else for a credit note to net against.
|
||||||
|
- **Stock Ledger** ([02_stock_ledger.md](./02_stock_ledger.md)): `SaleReturn` credits
|
||||||
|
`StockBalance` at the active ride's vehicle (`MovementType.CustomerReturn`), the same enum value
|
||||||
|
`StockService.RecordCustomerReturnAsync` already defined but that no sales-side flow was wiring
|
||||||
|
up until this step. `SupplierClaim` writes no stock movement at all (see Business Rules).
|
||||||
|
- **Ride Execution** ([05_delivery_ride.md](./05_delivery_ride.md)): a `SaleReturn` can only be
|
||||||
|
recorded against an order created during an active ride (`Sale.FK_RideId` must be set), since
|
||||||
|
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`).
|
||||||
|
- **Region Management** ([10_region_management.md](./10_region_management.md)): there is no
|
||||||
|
separate Agency entity in the codebase — "Regional Agency" is a `Role`
|
||||||
|
(`Data/Seed/RoleAndPermissionSeed.cs`), so `Claim` references the `User` holding that role.
|
||||||
|
- **Procurement** ([15_procurement.md](./15_procurement.md)): `SupplierClaim.FK_PurchaseOrderId`
|
||||||
|
is required, `FK_GoodsReceiptId` optional; a short-shipment is already excluded from stock at
|
||||||
|
receipt time (`GoodsReceiptItem.RejectedQuantity`), so `SupplierClaim` never needs to touch
|
||||||
|
`StockBalance` itself.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
### Return Request
|
Six new tables.
|
||||||
|
|
||||||
`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`,
|
### SaleReturn
|
||||||
`Rejected`), `RequestedByUserId`.
|
```
|
||||||
|
SaleReturn
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_OrderId (Guid) — required, the order this return is filed against
|
||||||
|
├── Mode (enum) — CreditDeduction, ItemExchange — chosen by the rep per return
|
||||||
|
├── TotalCreditAmount (decimal)— sum(Quantity * UnitPrice) for CreditDeduction mode; 0 for ItemExchange
|
||||||
|
├── CreatedByUserId (Guid) — required, the rep who recorded it
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
### Credit Note
|
### SaleReturnItem
|
||||||
|
```
|
||||||
|
SaleReturnItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SaleReturnId (Guid) — required
|
||||||
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Quantity (decimal) — returned quantity, credited back to the ride's vehicle
|
||||||
|
├── ReasonCode (enum) — Expired, Damaged, OrderError, Other
|
||||||
|
├── UnitPrice (decimal) — snapshot at return time (from the matching OrderItem if one
|
||||||
|
│ exists on this order, else Item.SalePrice)
|
||||||
|
```
|
||||||
|
|
||||||
`ReturnRequestId`, `InvoiceId`, `Amount`, `IssuedAt`.
|
### CreditNote
|
||||||
|
```
|
||||||
|
CreditNote
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SaleReturnId (Guid) — required
|
||||||
|
├── Amount (decimal) — amount netted off the order's TotalAmount
|
||||||
|
├── IssuedAt (DateTime)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
Audit-trail only — written for `CreditDeduction`-mode returns, not itself an approval gate or an
|
||||||
|
invoice-linked document (no `Invoice` entity exists yet; see Background/Context).
|
||||||
|
|
||||||
### Claim
|
### Claim
|
||||||
|
```
|
||||||
|
Claim
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_AgencyUserId (Guid) — required, the User holding the RegionalAgency role this claim is against
|
||||||
|
├── Details (string) — required
|
||||||
|
├── Status (enum) — Open, Settled
|
||||||
|
├── SettlementAmount (decimal?)— set once settled
|
||||||
|
├── SettledByUserId (Guid?), SettledAt (DateTime?)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
Independent of `SaleReturn`/stock — its own open/settle workflow.
|
||||||
|
|
||||||
`AgencyId`, `Details`, `Status` (`Open`, `Settled`), `SettlementAmount` (nullable until settled).
|
### SupplierClaim
|
||||||
|
```
|
||||||
|
SupplierClaim
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_PurchaseOrderId (Guid) — required
|
||||||
|
├── FK_GoodsReceiptId (Guid?) — optional; set when the claim stems from a specific receipt
|
||||||
|
│ (e.g. short/damaged at receipt)
|
||||||
|
├── Details (string) — description of the issue
|
||||||
|
├── EvidenceUrl (string?) — link/path to a photo or document; no file-upload storage
|
||||||
|
│ exists yet, so this is a plain string field for now
|
||||||
|
├── Status (enum) — Open, Approved, Rejected
|
||||||
|
├── ResolutionType (enum?) — Replacement, Credit, Refund, PriceAdjustment — set on approve
|
||||||
|
├── ResolutionAmount (decimal?) — for Credit / Refund / PriceAdjustment
|
||||||
|
├── ResolutionNotes (string?)
|
||||||
|
├── RaisedByUserId (Guid)
|
||||||
|
├── ResolvedByUserId (Guid?), ResolvedAt (DateTime?)
|
||||||
|
├── IsActive, CreatedAt — from Template
|
||||||
|
```
|
||||||
|
|
||||||
|
### SupplierClaimItem
|
||||||
|
```
|
||||||
|
SupplierClaimItem
|
||||||
|
├── Id (Guid) — PK, from Template
|
||||||
|
├── FK_SupplierClaimId (Guid) — required
|
||||||
|
├── FK_ItemId (Guid) — required
|
||||||
|
├── Quantity (decimal)
|
||||||
|
├── IssueType (enum) — Damaged, Short, Expired, WrongItem, Other
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Two migrations, applied:
|
||||||
|
1. **AddReturnsAndClaims** (`20260825073453_AddReturnsAndClaims`) — created `SaleReturns`,
|
||||||
|
`SaleReturnItems`, `CreditNotes`, `Claims`, with FKs to `Orders`/`Items`/`Users` and indexes on
|
||||||
|
every FK column.
|
||||||
|
2. **AddSupplierClaims** (`20260825084218_AddSupplierClaims`) — created `SupplierClaims`,
|
||||||
|
`SupplierClaimItems`, with FKs to `PurchaseOrders`/`GoodsReceipts`/`Items`/`Users` and indexes on
|
||||||
|
every FK column.
|
||||||
|
|
||||||
|
Both applied via `dotnet ef database update`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Function reference | Endpoint intent |
|
> Return routes use bare `[Authorize]` — matches the Supplier/PriceList/Procurement convention (no
|
||||||
|
> per-action policy). `claims.settle` and `suppliers.claims.approve` are the two enforced policies,
|
||||||
|
> since those are the two steps with real approval weight. See Permissions to Add to Seed.
|
||||||
|
|
||||||
|
### Returns (`Features/Returns`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `createReturnRequest(orderId, items[], reasonCode)` | `POST /api/returns` |
|
| `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles |
|
||||||
| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` |
|
| `getSaleReturn(returnId)` | `GET /api/returns/{id}` |
|
||||||
| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval |
|
| `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` |
|
||||||
| `createClaim(agencyId, claimDetails)` | `POST /api/claims` |
|
|
||||||
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` |
|
### Claims (`Features/Claims`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `createClaim(agencyUserId, details)` | `POST /api/claims` |
|
||||||
|
| `getClaim(claimId)` | `GET /api/claims/{id}` |
|
||||||
|
| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` — `[Authorize(Policy = "claims.settle")]` |
|
||||||
|
|
||||||
|
### Supplier Claims (`Features/SupplierClaims`)
|
||||||
|
| Function reference | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `createSupplierClaim(poId, grnId?, items[], details, evidenceUrl?)` | `POST /api/purchase-orders/{id}/claims` |
|
||||||
|
| `getSupplierClaim(claimId)` | `GET /api/supplier-claims/{id}` |
|
||||||
|
| `listSupplierClaims(poId)` | `GET /api/purchase-orders/{id}/claims` |
|
||||||
|
| `approveSupplierClaim(claimId, resolutionType, resolutionAmount?, notes?)` | `POST /api/supplier-claims/{id}/approve` — `[Authorize(Policy = "suppliers.claims.approve")]` |
|
||||||
|
| `rejectSupplierClaim(claimId, notes?)` | `POST /api/supplier-claims/{id}/reject` — `[Authorize(Policy = "suppliers.claims.approve")]` |
|
||||||
|
|
||||||
|
`approve`/`reject` combine the "inspect the claim" and "decide the outcome" steps into one call —
|
||||||
|
unlike the customer-side flow there's no separate physical-inspection step to wait on here, since
|
||||||
|
the evidence (photos/notes, and `GoodsReceiptItem.RejectedQuantity` if applicable) is already
|
||||||
|
attached at claim-creation time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / Response Shapes
|
||||||
|
|
||||||
|
### POST `/api/orders/{id}/returns`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "CreditDeduction",
|
||||||
|
"items": [{ "itemId": "...", "quantity": 5, "reasonCode": "Expired" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`mode` is `CreditDeduction` or `ItemExchange`, `reasonCode` is `Expired` | `Damaged` |
|
||||||
|
`OrderError` | `Other`.
|
||||||
|
|
||||||
|
### POST `/api/claims`
|
||||||
|
```json
|
||||||
|
{ "agencyUserId": "...", "details": "Short-shipped 20 units on last consignment" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/claims/{id}/settle`
|
||||||
|
```json
|
||||||
|
{ "settlementAmount": 1500.00 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/purchase-orders/{id}/claims`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"goodsReceiptId": "...",
|
||||||
|
"details": "20 units arrived damaged, 480 of 500 received",
|
||||||
|
"evidenceUrl": "https://...",
|
||||||
|
"items": [{ "itemId": "...", "quantity": 20, "issueType": "Damaged" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`goodsReceiptId` and `evidenceUrl` are optional. `issueType` is `Damaged` | `Short` | `Expired` |
|
||||||
|
`WrongItem` | `Other`.
|
||||||
|
|
||||||
|
### POST `/api/supplier-claims/{id}/approve`
|
||||||
|
```json
|
||||||
|
{ "resolutionType": "Credit", "resolutionAmount": 850.00, "resolutionNotes": "Agreed with supplier" }
|
||||||
|
```
|
||||||
|
`resolutionType` is `Replacement` | `Credit` | `Refund` | `PriceAdjustment`. `resolutionAmount` is
|
||||||
|
required unless `resolutionType` is `Replacement`.
|
||||||
|
|
||||||
|
### POST `/api/supplier-claims/{id}/reject`
|
||||||
|
```json
|
||||||
|
{ "resolutionNotes": "Discrepancy within accepted tolerance" }
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Business Rules
|
## Business Rules
|
||||||
|
|
||||||
- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule
|
1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This
|
||||||
already stated in [09_fraud_controls.md](./09_fraud_controls.md).
|
deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see
|
||||||
- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly
|
Background/Context) since a rep records the return on the spot during a live store visit.
|
||||||
the `ReturnWithoutSale` anomaly flagged automatically in
|
2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or
|
||||||
[09_fraud_controls.md](./09_fraud_controls.md).
|
`PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since
|
||||||
- Approving a return creates a `Return` stock movement crediting the relevant location, per
|
stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`,
|
||||||
[02_stock_ledger.md](./02_stock_ledger.md).
|
`MovementType.CustomerReturn`).
|
||||||
|
3. `createSaleReturn` rejects (409 Conflict) any line item the store has never had on **any** of
|
||||||
|
its past orders — checked across the store's full order history, not just the order the return
|
||||||
|
is filed against. This lets an expired or damaged item bought weeks ago still be returned; it
|
||||||
|
only blocks an item that store never bought at all — the real fraud case originally scoped as
|
||||||
|
`ReturnWithoutSale` in [09_fraud_controls.md](./09_fraud_controls.md), which is not otherwise
|
||||||
|
built.
|
||||||
|
4. `Mode = CreditDeduction`: the returned lines' value (`Quantity * UnitPrice`) is subtracted from
|
||||||
|
`Order.TotalAmount` (floored at 0), and a `CreditNote` audit row is written.
|
||||||
|
5. `Mode = ItemExchange`: the returned quantity is subtracted directly from the matching
|
||||||
|
`OrderItem.Quantity` on the same order (a physical swap of the same item), `TotalAmount`
|
||||||
|
adjusted by the offset amount. No `CreditNote` is written. A returned item with no matching line
|
||||||
|
on the order is credited to stock but does not offset anything on the order.
|
||||||
|
6. Stock credit, the `SaleReturn`/`SaleReturnItem` insert, and the order-total/credit-note update
|
||||||
|
all run inside one `IUnitOfWork` transaction — a failure anywhere rolls back the whole return.
|
||||||
|
7. `settleClaim` can only run once per claim — rejects (409) if already `Settled`. Claims never
|
||||||
|
touch `StockBalance` or any `Order`.
|
||||||
|
8. `createSupplierClaim` requires the referenced `PurchaseOrder` to exist and be active;
|
||||||
|
`FK_GoodsReceiptId`, if given, must belong to that PO (rejects with 409 otherwise).
|
||||||
|
9. Only `Open` supplier claims can be approved or rejected; both are terminal (no re-opening) —
|
||||||
|
enforced by `ApproveAsync`/`RejectAsync` throwing if `Status != Open`.
|
||||||
|
10. `approveSupplierClaim` requires `ResolutionType`; `ResolutionAmount` is required for `Credit`,
|
||||||
|
`Refund`, and `PriceAdjustment` (409 if missing), and is discarded (`null`) for `Replacement`
|
||||||
|
even if supplied.
|
||||||
|
11. Supplier claim approval/rejection is a real sign-off step (unlike the customer-side
|
||||||
|
`SaleReturn`, which settles immediately) — gated behind
|
||||||
|
`[Authorize(Policy = "suppliers.claims.approve")]`, following the Ride cancel-request pattern
|
||||||
|
(`RideController.cs:120-146`).
|
||||||
|
12. No automatic `StockBalance`/`StockMovement` write from any `SupplierClaim` action.
|
||||||
|
`ResolutionType = Replacement` records that new stock is expected; the actual stock credit
|
||||||
|
happens through an ordinary follow-up `GoodsReceipt` once it arrives (see Open Questions).
|
||||||
|
`Credit`/`Refund`/`PriceAdjustment` have no `Invoice`/Finance ledger to post against yet
|
||||||
|
(step 19, not built) — `ResolutionAmount` is captured as an audit field only, the same
|
||||||
|
limitation `CreditNote` has on the customer side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions to Add to Seed
|
||||||
|
|
||||||
|
**Status: `claims.settle` and `suppliers.claims.approve` enforced; return-creation routes
|
||||||
|
intentionally left as bare `[Authorize]`**, matching the Supplier/PriceList/Procurement convention
|
||||||
|
(steps 14/15) rather than introducing enforcement piecemeal. Both enforced policies use the same
|
||||||
|
dynamic policy-provider pattern as `rides.cancel.approve` (`PermissionPolicyProvider`) — no
|
||||||
|
explicit policy registration needed, just a seeded `Permission` row and a `RolePermission` grant.
|
||||||
|
No role assignments seeded yet; `suppliers.claims.approve` likely AreaManager (30) and above,
|
||||||
|
matching the Supplier/Procurement tier.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
- Should return-creation routes eventually get a real permission policy (`returns.create`) once
|
||||||
|
the Supplier/PriceList/Procurement group does, or do they stay bare `[Authorize]` indefinitely
|
||||||
|
since a rep needs to record a return without waiting on a scoped grant?
|
||||||
|
- `ItemExchange` mode clamps the offset to `min(returnedQty, orderItem.Quantity)` — if a rep
|
||||||
|
returns more of an item than is on the current order, the excess is credited to stock but not
|
||||||
|
reflected anywhere on the order. Worth a `CreditNote`-style fallback for the excess once this
|
||||||
|
edge case actually comes up.
|
||||||
|
- No `Invoice` entity exists yet (Finance, step 19) — `CreditNote` and `SupplierClaim`'s
|
||||||
|
`ResolutionAmount` are standalone audit fields for now, not linked to any invoice/ledger
|
||||||
|
document. Revisit once Finance & Accounting is built.
|
||||||
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
|
- Are `Claim`s only against Regional Agencies (as the reference doc frames them), or can a claim
|
||||||
also be raised against an internal warehouse/supplier discrepancy?
|
also be raised against an internal warehouse/supplier discrepancy? Currently only
|
||||||
|
`FK_AgencyUserId` (a User with the RegionalAgency role) is supported.
|
||||||
|
- `SupplierClaim.ResolutionType = Replacement` has no link back to the eventual replacement
|
||||||
|
`GoodsReceipt` — a nullable `FK_SupplierClaimId` on `GoodsReceipt` would close that loop but
|
||||||
|
isn't built yet.
|
||||||
|
- **Inspection & Classification (deferred)**: the fuller process — inspect returned goods,
|
||||||
|
classify each line as Sellable / Damaged / Expired / Quarantine / Disposal, route sellable stock
|
||||||
|
back to available inventory while damaged/expired stock moves to a separate stock category, and
|
||||||
|
only then decide refund vs. replacement vs. credit vs. reject — is explicitly out of scope for
|
||||||
|
now. The current `SaleReturn` flow settles immediately with no inspection step (see
|
||||||
|
Background/Context for why). When it's picked up, it will need: an `InspectionResult` per
|
||||||
|
`SaleReturnItem` recorded after the item is physically back at a warehouse (not at the point of
|
||||||
|
return); a stock-location/category concept for quarantined or condemned stock (today
|
||||||
|
`StockBalance` only distinguishes `LocationType.Warehouse`/`Vehicle`); a real cash-refund concept
|
||||||
|
once Finance/Payments (step 19) exists to reverse a `Payment`; and a decision on whether
|
||||||
|
inspection reopens the immediate-settlement model into a provisional-then-confirmed one — a
|
||||||
|
bigger behavioral change than adding a field, needing its own planning pass first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
> Fill this section in during planning discussion before writing any code.
|
### File structure (as built)
|
||||||
|
```
|
||||||
|
Features/
|
||||||
|
Returns/
|
||||||
|
ReturnController.cs — /api/orders/{id}/returns (POST/GET), /api/returns/{id} (GET)
|
||||||
|
ReturnDTOs.cs — CreateSaleReturnRequest, ReturnItemRequest, SaleReturnResponse(+Item)
|
||||||
|
ReturnService.cs — CreateAsync (validate → credit stock → net order → commit),
|
||||||
|
GetByIdAsync, GetByOrderIdAsync
|
||||||
|
IReturnRepository.cs / ReturnRepository.cs — includes StoreHasPriorSaleAsync
|
||||||
|
Claims/
|
||||||
|
ClaimController.cs — /api/claims (POST/GET), /api/claims/{id}/settle (POST)
|
||||||
|
ClaimDTOs.cs — CreateClaimRequest, SettleClaimRequest, ClaimResponse
|
||||||
|
ClaimService.cs — CreateAsync, SettleAsync, GetByIdAsync, GetByAgencyUserIdAsync
|
||||||
|
IClaimRepository.cs / ClaimRepository.cs
|
||||||
|
SupplierClaims/
|
||||||
|
SupplierClaimController.cs — /api/purchase-orders/{id}/claims (POST/GET),
|
||||||
|
/api/supplier-claims/{id} (GET),
|
||||||
|
/api/supplier-claims/{id}/approve|reject (POST)
|
||||||
|
SupplierClaimDTOs.cs — CreateSupplierClaimRequest, ApproveSupplierClaimRequest,
|
||||||
|
RejectSupplierClaimRequest, SupplierClaimResponse(+Item)
|
||||||
|
SupplierClaimService.cs — CreateAsync, ApproveAsync, RejectAsync, GetByIdAsync,
|
||||||
|
GetByPurchaseOrderIdAsync
|
||||||
|
ISupplierClaimRepository.cs / SupplierClaimRepository.cs
|
||||||
|
|
||||||
|
Models/
|
||||||
|
SaleReturn.cs, SaleReturnItem.cs, CreditNote.cs, Claim.cs (new)
|
||||||
|
SupplierClaim.cs, SupplierClaimItem.cs (new)
|
||||||
|
Enums.cs (extended — ReturnMode, ReturnReasonCode, ClaimStatus, SupplierClaimStatus,
|
||||||
|
SupplierClaimIssueType, SupplierClaimResolutionType added)
|
||||||
|
|
||||||
|
Data/AppDBContext.cs
|
||||||
|
DbSets added for all six entities; FK relationships via Fluent API, DeleteBehavior.Restrict
|
||||||
|
(SupplierClaim.FK_GoodsReceiptId, ResolvedByUserId configured as optional)
|
||||||
|
|
||||||
|
Migrations/
|
||||||
|
20260825073453_AddReturnsAndClaims (applied)
|
||||||
|
20260825084218_AddSupplierClaims (applied)
|
||||||
|
|
||||||
|
Program.cs
|
||||||
|
IReturnRepository/ReturnRepository, IClaimRepository/ClaimRepository,
|
||||||
|
ISupplierClaimRepository/SupplierClaimRepository registered;
|
||||||
|
ReturnService, ClaimService, SupplierClaimService registered
|
||||||
|
```
|
||||||
|
|
||||||
|
### How the models connect
|
||||||
|
```
|
||||||
|
Order (existing) ◄── FK_OrderId ── SaleReturn ── Mode: CreditDeduction | ItemExchange
|
||||||
|
│
|
||||||
|
├─ SaleReturnItem (FK_ItemId, Quantity, ReasonCode, UnitPrice)
|
||||||
|
│ └─► StockBalance/StockMovement (credited: vehicle of
|
||||||
|
│ the order's ride, MovementType.CustomerReturn)
|
||||||
|
│
|
||||||
|
└─ CreditNote (only when Mode = CreditDeduction;
|
||||||
|
Amount = sum(Quantity * UnitPrice))
|
||||||
|
|
||||||
|
Claim ── FK_AgencyUserId ──► User (holding the RegionalAgency role, seeded in
|
||||||
|
Data/Seed/RoleAndPermissionSeed.cs) — no separate Agency entity
|
||||||
|
|
||||||
|
PurchaseOrder (existing, step 15) ◄── FK_PurchaseOrderId ── SupplierClaim
|
||||||
|
│
|
||||||
|
GoodsReceipt (existing, step 15) ◄── FK_GoodsReceiptId (optional) ┘
|
||||||
|
│
|
||||||
|
├─ SupplierClaimItem (FK_ItemId,
|
||||||
|
│ Quantity, IssueType)
|
||||||
|
└─ ResolutionType: Replacement |
|
||||||
|
Credit | Refund | PriceAdjustment
|
||||||
|
(no stock/ledger write — see
|
||||||
|
Business Rules)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Return flow (as implemented)
|
||||||
|
```
|
||||||
|
ReturnService.CreateAsync(orderId, request, createdByUserId):
|
||||||
|
order = load Order, must be Pending/PartiallyFulfilled else throw
|
||||||
|
sale = load order's Sale, must have FK_RideId set else throw
|
||||||
|
ride = load Sale's Ride → vehicleId = ride.FK_VehicleId
|
||||||
|
orderItems = load order's OrderItems (for price snapshot + ItemExchange offset)
|
||||||
|
for each (itemId, qty, reasonCode) in request.Items:
|
||||||
|
guard StoreHasPriorSaleAsync(order.FK_StoreId, itemId) else throw 409
|
||||||
|
unitPrice = matching OrderItem.UnitPrice if present, else Item.SalePrice
|
||||||
|
begin transaction:
|
||||||
|
for each line: credit StockBalance(itemId, vehicleId, Vehicle) += qty
|
||||||
|
add StockMovement(Type = CustomerReturn, ToLocation = vehicle, ReferenceId = return.Id)
|
||||||
|
insert SaleReturn + SaleReturnItems
|
||||||
|
if Mode == CreditDeduction:
|
||||||
|
total = sum(qty * unitPrice); SaleReturn.TotalCreditAmount = total
|
||||||
|
Order.TotalAmount = max(0, Order.TotalAmount - total)
|
||||||
|
insert CreditNote(Amount = total)
|
||||||
|
else (ItemExchange):
|
||||||
|
for each line with a matching OrderItem:
|
||||||
|
offset = min(qty, orderItem.Quantity); orderItem.Quantity -= offset
|
||||||
|
Order.TotalAmount = max(0, Order.TotalAmount - offset * orderItem.UnitPrice)
|
||||||
|
commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claim flow (as implemented)
|
||||||
|
```
|
||||||
|
ClaimService.CreateAsync(request): insert Claim(Status = Open)
|
||||||
|
ClaimService.SettleAsync(claimId, settlementAmount, approverId):
|
||||||
|
guard claim exists and Status != Settled else throw
|
||||||
|
Claim.Status = Settled; SettlementAmount = settlementAmount
|
||||||
|
Claim.SettledByUserId = approverId; SettledAt = now
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supplier claim flow (as implemented)
|
||||||
|
```
|
||||||
|
SupplierClaimService.CreateAsync(purchaseOrderId, request, raisedByUserId):
|
||||||
|
po = load PurchaseOrder, must exist and be active else throw
|
||||||
|
guard Details non-empty, Items non-empty
|
||||||
|
if GoodsReceiptId given: guard it exists and belongs to this PO else throw
|
||||||
|
insert SupplierClaim(Status = Open) + SupplierClaimItems
|
||||||
|
|
||||||
|
SupplierClaimService.ApproveAsync(id, request, approverId):
|
||||||
|
guard claim exists and Status == Open else throw
|
||||||
|
guard ResolutionAmount present unless ResolutionType == Replacement else throw
|
||||||
|
Status = Approved; ResolutionType/Amount/Notes set; ResolvedByUserId = approverId; ResolvedAt = now
|
||||||
|
|
||||||
|
SupplierClaimService.RejectAsync(id, request, approverId):
|
||||||
|
guard claim exists and Status == Open else throw
|
||||||
|
Status = Rejected; ResolutionNotes set; ResolvedByUserId = approverId; ResolvedAt = now
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user