Update procurement docs

This commit is contained in:
2026-08-25 14:48:02 +05:30
parent f244a04dc0
commit fe9ccb4de7
2 changed files with 433 additions and 29 deletions
+9
View File
@@ -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
+424 -29
View File
@@ -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
```