From fe9ccb4de75f552b4fe9a77d398a53407ee1c5cc Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 25 Aug 2026 14:48:02 +0530 Subject: [PATCH] Update procurement docs --- Distribution/docs/15_procurement.md | 9 + Distribution/docs/18_returns_claims.md | 453 +++++++++++++++++++++++-- 2 files changed, 433 insertions(+), 29 deletions(-) diff --git a/Distribution/docs/15_procurement.md b/Distribution/docs/15_procurement.md index aa2f165..274d8b9 100644 --- a/Distribution/docs/15_procurement.md +++ b/Distribution/docs/15_procurement.md @@ -122,6 +122,15 @@ GoodsReceiptItem ``` ### 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 ├── Id (Guid) — PK, from Template diff --git a/Distribution/docs/18_returns_claims.md b/Distribution/docs/18_returns_claims.md index 653d46e..ef2dce5 100644 --- a/Distribution/docs/18_returns_claims.md +++ b/Distribution/docs/18_returns_claims.md @@ -1,74 +1,469 @@ # 18 — Returns & Claims Management -**Status**: Not Started -**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution +**Status**: Done — migrations applied to the database. Inspection/classification for customer +returns is deferred (see Open Questions). +**Depends On**: 05 — Delivery Ride Execution, 06 — Sale Ride Execution, 15 — Procurement (for +`SupplierClaim`) --- ## Goal -Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) in -full. [05_delivery_ride.md](./05_delivery_ride.md) and [09_fraud_controls.md](./09_fraud_controls.md) -both reference this step's credit note and approval shapes — this is where those are actually -defined. +Covers [mainPlan.md §12](./mainPlan.md#12-returns--claims-management) — a customer-side flow where +a sales rep records a return during a store visit and a claim workflow against a Regional Agency, +plus a supplier-side flow for claims against a `PurchaseOrder`/`GoodsReceipt` that don't involve +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 -No return, credit note, or claim entity exists today. `createBackorder` (§1, -[05_delivery_ride.md](./05_delivery_ride.md)) needs a credit note target once this step lands. +No return, credit note, or claim entity existed before this step. `createBackorder` (§1, +[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 -### Return Request +Six new tables. -`OrderId`, `Items[]` (`ItemId`, `Quantity`, `ReasonCode`), `Status` (`Pending`, `Approved`, -`Rejected`), `RequestedByUserId`. +### SaleReturn +``` +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 +├── 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 -| 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` | -| `approveReturn(returnId, approverId)` | `POST /api/returns/{id}/approve` | -| `generateCreditNote(returnId)` | `POST /api/returns/{id}/credit-note` — auto-triggered on approval | -| `createClaim(agencyId, claimDetails)` | `POST /api/claims` | -| `settleClaim(claimId, settlementAmount)` | `POST /api/claims/{id}/settle` | +| `createSaleReturn(orderId, mode, items[])` | `POST /api/orders/{id}/returns` — creates and immediately settles | +| `getSaleReturn(returnId)` | `GET /api/returns/{id}` | +| `listSaleReturns(orderId)` | `GET /api/orders/{id}/returns` | + +### 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 -- `approveReturn` requires supervisor sign-off before stock is credited back — matches the rule - already stated in [09_fraud_controls.md](./09_fraud_controls.md). -- A `Return Request` must reference a real prior sale/delivery line — the absence of one is exactly - the `ReturnWithoutSale` anomaly flagged automatically in - [09_fraud_controls.md](./09_fraud_controls.md). -- Approving a return creates a `Return` stock movement crediting the relevant location, per - [02_stock_ledger.md](./02_stock_ledger.md). +1. `createSaleReturn` is the entry point — no prior approval step, settlement is immediate. This + deliberately supersedes the original spec's `Pending → Approved/Rejected` flow (see + Background/Context) since a rep records the return on the spot during a live store visit. +2. A return can only be filed against an order that is still open (`OrderStatus.Pending` or + `PartiallyFulfilled`) — not `Fulfilled`/`Cancelled` — and whose `Sale.FK_RideId` is set, since + stock is credited back to that ride's vehicle (`DeliveryRun.FK_VehicleId`, + `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 +- 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 - 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 -> 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 +```