price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
+297
View File
@@ -0,0 +1,297 @@
# Dual Approval Plan — Stock Adjustments & Price Adjustments
> **Status:** implemented 2026-10-12 with the §8 recommended defaults (counts go through approval; initial prices on
> a new item need none; no Admin bypass; thresholds configurable, default "always two"; selling prices only; no future
> effective dates; no auto-expiry). Deviations: the approval doc type for price adjustments is `PADJ` (not `PRICE`);
> count-variance serial moves are recorded against the adjustment rather than the count; the Approvals inbox is open
> to every signed-in user (it lists only what they may approve and their own requests). Details in
> `Backend/PROGRESS.md` and `Frontend/PROGRESS.md`.
> **Goal:** a stock adjustment or a selling-price change only takes effect after **two different people**
> (neither of them the person who requested it) have approved it.
> **Reference to mirror:** `PurchaseOrderService` — `PendingApproval` → `Approved`/`Rejected`, the
> `Approve` action permission, and the "you cannot approve your own document" rule. This plan extends that
> one-step approval to two steps and puts it in one shared place so both documents use the same rules.
---
## 1. Requirements
| # | Requirement |
|---|---|
| 1 | A stock adjustment (including wastage) changes stock and posts to GL **only after the second approval**. |
| 2 | A selling-price change (min / max / flat price) is applied to the item **only after the second approval**. |
| 3 | Maker–checker: the requester can't approve. Approver 1 and approver 2 must be **different people**. |
| 4 | Either approver can **reject** with a reason. A rejected request never takes effect. |
| 5 | Everyone involved is notified: approvers when something is waiting for them, the requester when it is approved or rejected. |
| 6 | A full audit trail: who requested, who approved at each level, when, and any comments. |
| 7 | An **Approvals** inbox listing everything waiting for the current user. |
---
## 2. Current state
| Area | What exists | Gap |
|---|---|---|
| Stock adjustments | `AdjustmentService.CreateAsync` **posts immediately**: it moves stock, posts GL and writes the ledger in one transaction. `AdjustmentStatus` already has `Draft`, `PendingApproval` and `Posted`, but only `Posted` is ever used. | No approval step at all. |
| Wastage | `/stock/wastage` records adjustments with wastage reason codes through the same path. | Same: posts immediately. |
| Stock counts | `CountService.PostAsync` creates a variance `StockAdjustment` and posts it immediately. | A count is effectively an adjustment, so it bypasses any approval added to adjustments (see §8, decision 3). |
| Selling prices | `Item.SellingMinPrice / SellingMaxPrice / SellingFlatPrice` are edited directly in the item Edit dialog and saved instantly. `SalesPricingService` reads them. | No price-change document, no history, no approval. |
| Approval pattern | Purchase orders: one approver, Admin only, not your own PO. `RolePermissionAction.Approve` exists and `NavPolicies.ApproveAction` is used on PO endpoints. | Only one level, and it's built into the PO service. |
| Notifications | `INotificationService.RaiseAsync` broadcasts to all active users (in-app only). | No way to notify only approvers or only the requester. |
---
## 3. Design overview
```
Requester ──create──► PendingApproval (level 1)
│ approve (user A ≠ requester)
▼
PendingSecondApproval (level 2)
│ approve (user B ≠ requester, ≠ A)
▼
Approved → effect applied in the same transaction
(stock posted + GL / prices written + history)
Reject at either level ──► Rejected (terminal; nothing applied)
Requester may Withdraw while still pending ──► Cancelled
```
- **One shared approval engine** (`ApprovalService`) holds the rules: who may approve, the two-person rule,
and the audit rows. The two document services call it, and only apply their effect when it says
"fully approved".
- **Nothing changes stock or prices until the final approval.** Pending documents are only requests.
- **Re-check at final approval.** Stock may have moved since the request. A decrease that would now make
stock negative fails at approval time with a clear message, and the document stays pending.
---
## 4. Shared approval engine
### 4.1 Entity `DocumentApproval` (table `document_approvals`)
One row per decision, so the full trail is kept.
| Field | Notes |
|---|---|
| `DocumentApprovalId` | PK |
| `DocType` | `ADJ` (stock adjustment) or `PRICE` (price adjustment) |
| `DocId` | the document's id |
| `Level` | 1 or 2 |
| `Decision` | `Approved` / `Rejected` |
| `DecidedBy`, `DecidedAt` | the user and time |
| `Comment` | optional; required when rejecting |
### 4.2 `IApprovalService`
```csharp
// Validates the caller may decide at the document's current level, records the row,
// and returns where the document stands now.
Task<ApprovalOutcome> DecideAsync(string docType, int docId, int requestedBy, int currentLevel,
bool approve, string? comment, CancellationToken ct);
// ApprovalOutcome: NextLevel(2) | FullyApproved | Rejected
Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct);
```
**Rules enforced in one place:**
1. The caller has the `Approve` action on the document's nav code (`stock.adjustments` or `products.price-adjustments`).
2. The caller is not the requester (`ApprovalForbidden`, 403).
3. At level 2, the caller is not the level-1 approver (`ApprovalSamePerson`, 403).
4. The document is at the expected level (`ApprovalNotPending`, 409). This also stops two people approving the same level at once (row-version check).
5. **Optional, per settings (§7):** level 2 is limited to particular roles (for example, Manager or Admin).
**Admin bypass:** none. Even an Admin can't approve their own request or both levels (see §8, decision 5).
---
## 5. Stock adjustments
### 5.1 Status flow
`AdjustmentStatus` becomes: `Draft`, `PendingApproval`, `PendingSecondApproval`, `Posted`, `Rejected`, `Cancelled`.
Existing rows stay `Posted`.
### 5.2 Changes to `StockAdjustment`
- Add `RequestedBy` (the existing `CreatedBy`), `ApprovedLevel1By/At`, `ApprovedLevel2By/At`, `RejectedBy/At`, `RejectReason`.
- Add `PostedAt`. Today the ledger and GL use the creation time; they will use the **posting time**.
- **Inbound serials:** today, new serials are created at once from `SerialNos`. While a request is pending, the
typed serial numbers are stored on the line (new `StockAdjustmentLineSerialNo` rows or a JSON column) and the
serial records are created only when it posts.
- **Outbound serials** are validated when the request is made and **re-validated at posting**. A serial sold in the
meantime fails the approval with a message naming that serial.
### 5.3 `AdjustmentService`
| Method | Behaviour |
|---|---|
| `CreateAsync` | Validates as today (reason code, items, serials), saves the header and lines as `PendingApproval`. **No** stock movement and no GL. Notifies approvers. |
| `ApproveAsync(id, comment)` | `ApprovalService.DecideAsync`. On level 1: status becomes `PendingSecondApproval` and level-2 approvers are notified. On full approval: in one transaction, run today's posting code (`IStockMutator.ApplyAsync`, serial moves, `PostAdjustmentJournalAsync`), then set `Posted` and notify the requester. If posting fails (for example, negative stock), nothing is saved and the status stays `PendingSecondApproval`. |
| `RejectAsync(id, reason)` | Records the rejection, sets `Rejected` and notifies the requester. |
| `CancelAsync(id)` | Requester only, while pending: sets `Cancelled`. |
The posting code moves into a private `PostAsync(StockAdjustment)` so that create, approve and stock counts
(§8, decision 3) all share it.
### 5.4 API (`/api/v1/stock-adjustments`)
| Method | Route | Permission |
|---|---|---|
| POST | `/` | `stock.adjustments` with `Create` (now creates a **request**) |
| POST | `/{id}/approve` | `stock.adjustments` with `Approve` |
| POST | `/{id}/reject` | `stock.adjustments` with `Approve` |
| POST | `/{id}/cancel` | `stock.adjustments` (requester only, checked in the service) |
| GET | `/{id}` | now also returns the approval history and pending level |
| GET | `/` | new `status` filter |
---
## 6. Price adjustments (new document)
### 6.1 Entities
**`PriceAdjustment`** (`price_adjustments`): `PriceAdjustmentId`, `DocNo` (`PADJ-…`), `Reason`, `EffectiveDate`
(defaults to the approval date), `Status` (same flow as §5.1), the requested/approved/rejected stamps, `RowVersion`.
**`PriceAdjustmentLine`**: `ItemId`, plus the **old** values (snapshot when the request is made) and the **new** values for
`SellingMinPrice`, `SellingMaxPrice` and `SellingFlatPrice`.
**`ItemPriceHistory`** (`item_price_history`): one row per applied change. It holds the item, old and new values,
the `PriceAdjustmentId`, the date applied, and both approvers. This answers "what was the price on day X, and who
approved it?"
### 6.2 Rules
- **Validation when the request is made:** each item exists and is active; min ≤ flat ≤ max where given; at least one value differs from the current price.
- **One pending request per item:** a second pending request for the same item is rejected (`PriceAdjustmentItemPending`), so two requests can't overwrite each other.
- **At final approval, in one transaction:**
- If an item's current price no longer matches the request's "old" snapshot (someone changed it in between), that approval fails with `PriceAdjustmentStale`, and the requester raises a new request.
- Otherwise, write the new prices to `Item` and add an `ItemPriceHistory` row.
- **Effective date in the future (optional, §8 decision 6):** the request is approved but applied by a daily job on that date.
### 6.3 Closing the direct-edit route
Selling prices in the item **Edit dialog** become read-only, with a "Request price change" button that opens the
price-adjustment form pre-filled for that item. On the backend, `ItemService.UpdateAsync` stops changing price
fields: it rejects a change with `PriceChangeRequiresApproval`, or ignores the price fields (see §8, decision 4).
Prices set when an item is **first created** do not need approval (§8, decision 4).
### 6.4 API (`/api/v1/price-adjustments`)
List, get (with history), create, approve, reject and cancel, using the same permission pattern as §5.4 on a
new nav code `products.price-adjustments`.
There is also `GET /api/v1/items/{id}/price-history`.
---
## 7. Approval settings
A singleton `ApprovalConfig`, edited under **Settings → Approvals**:
| Setting | Default | Purpose |
|---|---|---|
| `StockAdjustment.Levels` | 2 | 0 = post immediately (today's behaviour), 1 = single approval, 2 = dual approval |
| `StockAdjustment.Level2ThresholdValue` | 0 | Adjustments whose absolute stock value is below this need only one approval (0 = always two) |
| `PriceAdjustment.Levels` | 2 | as above |
| `PriceAdjustment.Level2ThresholdPercent` | 0 | Changes smaller than this % need only one approval (0 = always two) |
| `Level2Roles` | empty | When set, only these roles can give the second approval |
---
## 8. Open decisions
| # | Question | Recommended default |
|---|---|---|
| 1 | **Who can approve:** anyone with the `Approve` action on the screen, or fixed roles? | Anyone with `Approve`, with `Level2Roles` (§7) available to restrict level 2 to managers. |
| 2 | **Thresholds:** dual approval for every adjustment, or only above a value? | Always two by default; thresholds configurable (§7). |
| 3 | **Stock counts:** should posting a count also go through dual approval? It creates an adjustment. | Yes. A count post creates its variance adjustment as `PendingApproval`, otherwise counts become a way around approval. |
| 4 | **Item edit and item creation:** block price edits in the Edit dialog? Do initial prices on a new item need approval? | Block in the Edit dialog (use "Request price change"). Initial prices on creation don't need approval. |
| 5 | **Admin bypass:** can an Admin approve their own request, or both levels? | No. The two-person rule applies to everyone. |
| 6 | **Future effective dates** for price changes? | Not in v1. A change applies when it is approved. |
| 7 | **Price adjustment scope:** selling prices only, or also stock cost (inventory revaluation)? | Selling prices only. Cost revaluation touches FIFO layers and GL and should be its own document. |
| 8 | **Expiry:** should a request still pending after N days be cancelled automatically? | No auto-expiry. The Approvals inbox shows its age. |
---
## 9. Notifications
`INotificationService` gets a targeted method next to the broadcast one:
```csharp
Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? entityType, int? entityId, CancellationToken ct);
```
New modules (in-app on by default, through a seed migration like `EnableLowStockAlerts`):
| Key | Sent to |
|---|---|
| `approval.pending` | users who can approve at the document's current level (excluding the requester and the level-1 approver) |
| `approval.approved` | the requester, when fully approved |
| `approval.rejected` | the requester, with the reason |
---
## 10. Frontend
- **Approvals inbox** (`/dashboard/approvals`, a sidebar badge with the count):
- Lists pending stock and price adjustments waiting for **me**, with their age, level (1/2) and requester.
- Approve / Reject (reject asks for a reason).
- **Stock → Adjustments:** status badges (Pending 1/2, Posted, Rejected, Cancelled); the detail page shows the approval timeline and Approve / Reject / Cancel buttons by role. The New form's button reads "Submit for approval".
- **Wastage:** the same status badges; recorded wastage shows "Pending approval" until posted.
- **Products → Price Adjustments** (new):
- The list.
- The **new** form: pick items, see current prices, enter new prices, reason.
- The detail page with the timeline.
- **Item page:** the prices in the Edit dialog are read-only, with "Request price change"; a new "Price history" panel.
- **Settings → Approvals:** the §7 settings.
---
## 11. Migration & rollout
- **Migration `AddDualApproval`:**
- Creates the `document_approvals`, `price_adjustments`, `price_adjustment_lines`, `item_price_history` and `approval_config` tables.
- Adds the new columns on `stock_adjustments`.
- Seeds the nav item and permission for `products.price-adjustments` and the notification modules.
- **Existing adjustments** stay `Posted`, with no approval rows.
- **Roll-out safety:** ship with `Levels` defaulting to 2, but tell admins to grant the `Approve` action to at least
**two** users per branch before deploying. Otherwise nothing can ever be approved.
---
## 12. Phases
1. **Approval engine:** `DocumentApproval`, `ApprovalService`, targeted notifications, `ApprovalConfig`.
2. **Stock adjustments:** status flow, deferred posting (including inbound serials), approve/reject/cancel endpoints; wastage and counts on the same path.
3. **Price adjustments:** entities, service, endpoints, price history, item-edit lock.
4. **Frontend:** Approvals inbox, adjustment/wastage updates, Price Adjustments pages, Settings → Approvals.
5. **Tests and docs** (§13), plus `PROGRESS.md` updates.
---
## 13. Verification
**Backend tests:**
- **Approval engine:**
- The requester can't approve.
- The same person can't approve both levels.
- A user without `Approve` is refused.
- Approving the wrong level is refused.
- A rejection needs a reason.
- **Stock adjustments:**
- Nothing moves until the second approval.
- After full approval, the ledger, GL and serials match today's immediate posting.
- An approval that would make stock negative fails and the adjustment stays pending.
- A rejected adjustment never posts.
- The value threshold drops it to one level.
- **Price adjustments:**
- Prices change only after the second approval, and a history row is written.
- The stale-price check works.
- A second pending request for the same item is refused.
- The item edit can no longer change prices.
- **Counts:** posting a count creates a pending adjustment, not a posted one.
**Manual checks:** a full two-user approval in the UI for each document, the notification for each step, and the Approvals inbox count.