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
+9
View File
@@ -26,4 +26,13 @@ public static class DocumentTypes
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
public const string SalesDayEnd = "DEND";
/// <summary>Business expense bill (Accounts) — <c>EXP-BR-001-2026-00001</c>.</summary>
public const string Expense = "EXP";
/// <summary>Till cash deposited to a bank account (Accounts) — <c>BD-BR-001-2026-00001</c>.</summary>
public const string BankDeposit = "BD";
/// <summary>Selling-price change request (Products) — <c>PADJ-2026-00001</c>.</summary>
public const string PriceAdjustment = "PADJ";
}
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §4). Saving it posts a GL entry
/// (Debit the bank account / Credit the cash account) and lowers the branch's cash in hand for
/// <see cref="BusinessDate"/>. There is no draft: a wrong deposit is cancelled (the entry is
/// reversed) and entered again. GL accounts have no local FK, so the account details are snapshots.
/// </summary>
public class BankDeposit
{
public int BankDepositId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int BranchId { get; set; }
public Branch? Branch { get; set; }
/// <summary>The day whose cash is deposited — cash in hand and the day-end report group by this.</summary>
public DateOnly BusinessDate { get; set; }
/// <summary>The day the bank received it; the GL entry date.</summary>
public DateOnly DepositDate { get; set; }
public decimal Amount { get; set; }
public long FromGlCashAccountId { get; set; }
public string FromCashAccountCode { get; set; } = string.Empty;
public string FromCashAccountName { get; set; } = string.Empty;
public long ToGlBankAccountId { get; set; }
public string ToBankAccountCode { get; set; } = string.Empty;
public string ToBankAccountName { get; set; } = string.Empty;
public string? ToBankName { get; set; }
public string? ToAccountNumber { get; set; }
public string? DepositSlipNo { get; set; }
public string? DepositedBy { get; set; }
public string? Remarks { get; set; }
public BankDepositStatus Status { get; set; } = BankDepositStatus.Posted;
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public string? GlReversalJournalNo { get; set; }
public string? CancelReason { get; set; }
public int? CancelledBy { get; set; }
public DateTime? CancelledAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -25,6 +25,11 @@ public class BundleSale
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
/// <summary>How the (always fully paid) <see cref="GrandTotal"/> was tendered:
/// <c>CashAmount + CardAmount == GrandTotal</c>. Backfilled as all cash.</summary>
public decimal CashAmount { get; set; }
public decimal CardAmount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
@@ -0,0 +1,48 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One approval decision on a document (docs/DUAL_APPROVAL_PLAN.md §4.1) — a row per decision, so
/// the full trail survives. Polymorphic like the stock ledger: <see cref="DocType"/> +
/// <see cref="DocId"/>, no FK. Doc types: <c>ADJ</c> (stock adjustment), <c>PADJ</c> (price adjustment).
/// </summary>
public class DocumentApproval
{
public int DocumentApprovalId { get; set; }
public string DocType { get; set; } = string.Empty;
public int DocId { get; set; }
/// <summary>1 or 2.</summary>
public int Level { get; set; }
public ApprovalDecision Decision { get; set; }
public int DecidedBy { get; set; }
public User? Decider { get; set; }
public DateTime DecidedAt { get; set; }
public string? Comment { get; set; }
}
/// <summary>
/// Approval rules (docs/DUAL_APPROVAL_PLAN.md §7). Singleton row, edited under Settings → Approvals.
/// Levels: 0 = no approval (applies on create), 1 = one approver, 2 = two different approvers.
/// </summary>
public class ApprovalConfig
{
public const int SingletonId = 1;
public int ApprovalConfigId { get; set; }
public int StockAdjustmentLevels { get; set; } = 2;
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
public int PriceAdjustmentLevels { get; set; } = 2;
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
public string? Level2RoleCodes { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,92 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One business expense bill (electricity, rent, …) outside procurement. Draft is editable;
/// Post accrues it to GL (Debit the title's expense account / Credit Accrued Expenses);
/// payments then settle the accrual in one or more installments (see
/// ExpensePaymentService). Model: docs/EXPENSE_MODULE_PLAN.md §3.3.
/// </summary>
public class Expense
{
public int ExpenseId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public DateOnly ExpenseDate { get; set; }
public DateOnly? DueDate { get; set; }
public int ExpenseCategoryId { get; set; }
public ExpenseCategory? Category { get; set; }
public int ExpenseTitleId { get; set; }
public ExpenseTitle? Title { get; set; }
public string? Payee { get; set; }
public int? VendorId { get; set; }
public Vendor? Vendor { get; set; }
public string? BillReference { get; set; }
public string? Description { get; set; }
/// <summary>Optional month the bill covers, as <c>yyyy-MM</c> (e.g. September's electricity).</summary>
public string? PeriodMonth { get; set; }
public decimal Amount { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
public ExpensePaymentStatus PaymentStatus { get; set; } = ExpensePaymentStatus.Unpaid;
/// <summary>Snapshot of the title's GL expense account at post time.</summary>
public string? GlExpenseAccountCode { get; set; }
/// <summary>Journal number of the accrual entry posted on Post.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>Journal number of the reversing entry posted on Cancel (Posted expenses only).</summary>
public string? GlReversalJournalNo { get; set; }
public string? CancelReason { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<ExpensePayment> Payments { get; set; } = new List<ExpensePayment>();
}
/// <summary>
/// A payment against a Posted expense's balance (installments allowed). Every line of one
/// payment submission shares the single GL journal entry that submission posted (Debit
/// Accrued Expenses / Credit each selected cash-or-bank account). Mirrors
/// <see cref="PurchaseInvoicePayment"/>.
/// </summary>
public class ExpensePayment
{
public int ExpensePaymentId { get; set; }
public int ExpenseId { get; set; }
public Expense? Expense { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
public ExpensePaymentMethod PaymentMethod { get; set; }
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
public long GlBankAccountId { get; set; }
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty;
public string? ChequeNo { get; set; }
public string? Reference { get; set; }
public string? GlJournalNo { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,48 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Top-level expense grouping (Utility, Rental, Salaries, Other). The four seeded rows are
/// <see cref="IsSystem"/> — they can be deactivated but not renamed; admins can add more.
/// Each category owns its <see cref="ExpenseTitle"/>s. Model: docs/EXPENSE_MODULE_PLAN.md §3.1.
/// </summary>
public class ExpenseCategory
{
public int ExpenseCategoryId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystem { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<ExpenseTitle> Titles { get; set; } = new List<ExpenseTitle>();
}
/// <summary>
/// A specific expense under a category (Utility → Electricity, Water, …). Carries the GL
/// expense account debited when an expense with this title is posted — snapshotted onto the
/// expense at post time, so changing it later does not touch already-posted expenses.
/// Model: docs/EXPENSE_MODULE_PLAN.md §3.2.
/// </summary>
public class ExpenseTitle
{
public int ExpenseTitleId { get; set; }
public int ExpenseCategoryId { get; set; }
public ExpenseCategory? Category { get; set; }
public string Name { get; set; } = string.Empty;
public string GlExpenseAccountCode { get; set; } = string.Empty;
public bool IsSystem { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,78 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A request to change items' selling prices (docs/DUAL_APPROVAL_PLAN.md §6). Nothing changes on the
/// items until it is fully approved; then each line's new prices are written to its item and an
/// <see cref="ItemPriceHistory"/> row is kept. <see cref="CreatedBy"/> is the requester.
/// </summary>
public class PriceAdjustment
{
public int PriceAdjustmentId { get; set; }
public string DocNo { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
public PriceAdjustmentStatus Status { get; set; } = PriceAdjustmentStatus.PendingApproval;
public int RequiredApprovals { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public int? ApprovedLevel1By { get; set; }
public DateTime? ApprovedLevel1At { get; set; }
public int? ApprovedLevel2By { get; set; }
public DateTime? ApprovedLevel2At { get; set; }
public int? RejectedBy { get; set; }
public DateTime? RejectedAt { get; set; }
public string? RejectReason { get; set; }
public DateTime? CancelledAt { get; set; }
/// <summary>When the new prices were written to the items.</summary>
public DateTime? AppliedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<PriceAdjustmentLine> Lines { get; set; } = new List<PriceAdjustmentLine>();
}
/// <summary>One item's change. The Old* values are the item's prices when the request was made —
/// approval fails if the item has been repriced since (stale request).</summary>
public class PriceAdjustmentLine
{
public int PriceAdjustmentLineId { get; set; }
public int PriceAdjustmentId { get; set; }
public PriceAdjustment? PriceAdjustment { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal? OldMinPrice { get; set; }
public decimal? OldMaxPrice { get; set; }
public decimal? OldFlatPrice { get; set; }
public decimal? NewMinPrice { get; set; }
public decimal? NewMaxPrice { get; set; }
public decimal? NewFlatPrice { get; set; }
}
/// <summary>An applied selling-price change: what it was, what it became, and who approved it.</summary>
public class ItemPriceHistory
{
public int ItemPriceHistoryId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int? PriceAdjustmentId { get; set; }
public PriceAdjustment? PriceAdjustment { get; set; }
public decimal? OldMinPrice { get; set; }
public decimal? OldMaxPrice { get; set; }
public decimal? OldFlatPrice { get; set; }
public decimal? NewMinPrice { get; set; }
public decimal? NewMaxPrice { get; set; }
public decimal? NewFlatPrice { get; set; }
public int RequestedBy { get; set; }
public int? ApprovedLevel1By { get; set; }
public int? ApprovedLevel2By { get; set; }
public DateTime AppliedAt { get; set; }
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
@@ -21,6 +23,9 @@ public class SalesInvoicePayment
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty;
/// <summary>How the customer paid. Payments recorded before this existed were backfilled as Cash.</summary>
public SalesPaymentMethod PaymentMethod { get; set; } = SalesPaymentMethod.Cash;
public string? Reference { get; set; }
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
@@ -27,6 +27,11 @@ public class SalesSlip
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>How <see cref="PaidAmount"/> was tendered: <c>CashAmount + CardAmount == PaidAmount</c>.
/// Slips from before the split existed were backfilled as all cash.</summary>
public decimal CashAmount { get; set; }
public decimal CardAmount { get; set; }
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
@@ -37,5 +37,20 @@ public class StockAdjustment
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
// --- Approval (docs/DUAL_APPROVAL_PLAN.md §5). CreatedBy is the requester. ---
/// <summary>Approvals this request needs (0 = posted on create, 1 or 2), fixed when it is made.</summary>
public int RequiredApprovals { get; set; }
public int? ApprovedLevel1By { get; set; }
public DateTime? ApprovedLevel1At { get; set; }
public int? ApprovedLevel2By { get; set; }
public DateTime? ApprovedLevel2At { get; set; }
public int? RejectedBy { get; set; }
public DateTime? RejectedAt { get; set; }
public string? RejectReason { get; set; }
public DateTime? CancelledAt { get; set; }
/// <summary>When stock actually moved — the ledger and GL use this, not <see cref="CreatedAt"/>.</summary>
public DateTime? PostedAt { get; set; }
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
}
@@ -21,4 +21,12 @@ public class StockAdjustmentLine
public Serial? Serial { get; set; }
public decimal QtyDelta { get; set; }
/// <summary>
/// The serials named on the request, kept until it posts (JSON arrays): outbound serial ids
/// to write off, inbound serial numbers found. Re-validated at posting, because a pending
/// request moves nothing and the serials may have moved in the meantime.
/// </summary>
public string? RequestedSerialIds { get; set; }
public string? RequestedSerialNos { get; set; }
}
@@ -1,13 +1,33 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval
/// workflow (FR-STK-07). Stored as a string.
/// Stock-adjustment lifecycle (docs/DUAL_APPROVAL_PLAN.md §5.1). A new adjustment is a request
/// (<see cref="PendingApproval"/>, then <see cref="PendingSecondApproval"/> when two approvals are
/// required) and only moves stock when it becomes <see cref="Posted"/>. With approvals switched off
/// (<c>ApprovalConfig.StockAdjustmentLevels = 0</c>) it posts on create, as before. Stored as a string.
/// </summary>
public enum AdjustmentStatus
{
Draft,
PendingApproval,
Posted
Posted,
PendingSecondApproval,
Rejected,
Cancelled
}
/// <summary>Price-adjustment lifecycle — same request flow as <see cref="AdjustmentStatus"/>; <see cref="Approved"/> means applied.</summary>
public enum PriceAdjustmentStatus
{
PendingApproval,
PendingSecondApproval,
Approved,
Rejected,
Cancelled
}
public enum ApprovalDecision
{
Approved,
Rejected
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>A deposit is posted as soon as it is saved; a wrong one is cancelled (GL reversed) and re-entered.</summary>
public enum BankDepositStatus
{
Posted,
Cancelled
}
@@ -0,0 +1,24 @@
namespace ERPCore.Domain.Enums;
public enum ExpenseStatus
{
Draft,
Posted,
Cancelled
}
/// <summary>Settlement state of a Posted expense, derived from its paid/balance amounts.</summary>
public enum ExpensePaymentStatus
{
Unpaid,
PartiallyPaid,
Paid
}
public enum ExpensePaymentMethod
{
Cash,
BankTransfer,
Cheque,
Card
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>How a customer paid for a sale — recorded per invoice payment (and split into
/// cash/card amounts on slips and bundle sales) so the Day-End Sales Report can break
/// collections down by method. Mirrors <see cref="ExpensePaymentMethod"/>.</summary>
public enum SalesPaymentMethod
{
Cash,
Card,
BankTransfer,
Cheque
}
@@ -27,6 +27,18 @@ public sealed class StockAdjustmentCreatedNotificationModule;
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
public sealed class StockTransferCreatedNotificationModule;
[NotificationModule("stock.low", "Low Stock / Reorder Alert", "Raised when a sale, transfer or adjustment takes an item's stock at a warehouse to or below its reorder point.", 95)]
public sealed class StockLowNotificationModule;
[NotificationModule("approval.pending", "Approval Needed", "Sent to the people who can approve a stock or price adjustment that is waiting for them.", 96)]
public sealed class ApprovalPendingNotificationModule;
[NotificationModule("approval.approved", "Request Approved", "Sent to the requester when their stock or price adjustment is fully approved and applied.", 97)]
public sealed class ApprovalApprovedNotificationModule;
[NotificationModule("approval.rejected", "Request Rejected", "Sent to the requester when their stock or price adjustment is rejected.", 98)]
public sealed class ApprovalRejectedNotificationModule;
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
public sealed class ItemCreatedNotificationModule;