Files
ERP-core/docs/DUAL_APPROVAL_PLAN.md

16 KiB
Raw Permalink Blame History

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

// 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:

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.