feat: Implement fixed sale price functionality for items

- Added a toggle for fixed sale price vs stock value in the item creation form.
- Introduced validation to ensure all variants have a price greater than 0 when fixed price mode is selected.
- Updated the item model to include a nullable salePrice field, which is used for sales only and does not affect GRN/FIFO/ledger.
- Enhanced the GRN page to allow off-PO items and included a refresh button to update the item list without reloading the page.
- Updated documentation to reflect changes in item pricing and GRN handling.
This commit is contained in:
2026-07-23 12:12:32 +05:30
parent 5cf9588728
commit a1b3985469
15 changed files with 245 additions and 25 deletions
+8
View File
@@ -33,6 +33,14 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
/// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
/// </summary>
public decimal? SalePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
+6 -2
View File
@@ -9,7 +9,7 @@ namespace ERPCore.Dtos.Items;
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
string? TaxClass, decimal? SalePrice, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -27,7 +27,7 @@ public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
@@ -62,6 +62,8 @@ public sealed class CreateItemRequest
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemRequest
@@ -79,6 +81,8 @@ public sealed class UpdateItemRequest
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemStatusRequest
@@ -19,6 +19,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
builder.Property(i => i.StockNature)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
@@ -385,6 +385,10 @@ namespace ERPCore.Infra.Persistence.Migrations
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<decimal?>("SalePrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<string>("Sku")
.IsRequired()
.HasMaxLength(50)
+4 -2
View File
@@ -79,7 +79,7 @@ public sealed class ItemService : IItemService
.Select(i => new ItemListItemDto(
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.Status))
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status))
.ToListAsync(ct);
return PagedResponse<ItemListItemDto>.Create(rows, query.Page, query.PageSize, total);
@@ -117,6 +117,7 @@ public sealed class ItemService : IItemService
StockNature = request.StockNature,
TrackingMode = request.TrackingMode,
TaxClass = request.TaxClass,
SalePrice = request.SalePrice,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -158,6 +159,7 @@ public sealed class ItemService : IItemService
item.StockNature = request.StockNature;
item.TrackingMode = request.TrackingMode;
item.TaxClass = request.TaxClass;
item.SalePrice = request.SalePrice;
item.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync(ct);
@@ -348,7 +350,7 @@ public sealed class ItemService : IItemService
private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
i.BaseUomId, i.DefaultVendorId,
i.StockNature, i.TrackingMode, i.TaxClass, i.Status,
i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status,
i.ReorderSettings
.OrderBy(r => r.WarehouseId)
.Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty))
+7 -1
View File
@@ -27,6 +27,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only
- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId`
- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes
- [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry.
> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2)
> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8.
@@ -75,6 +76,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## 3. Goods Receipt
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry.
- [x] **Off-PO lines on a PO-based GRN** (FR-GRN-01, 2026-07-22) — a line with `poLineId: null` on a PO-based GRN is received like a direct line (entered `unitCost`, no over-receipt check, PO balances untouched). **No code change was needed**`GrnService.CreateAsync` already branches per-line on `input.PoLineId is not null`; documented + frontend-enabled. Same review/audit surface as AR-04 (02-SECURITY C.3).
- [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
- [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
@@ -111,7 +113,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
## Done
<!-- move [x] items here with date + note if the active list grows long -->
### 2026-07-20PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`)
### 2026-07-22Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`)
- **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1.
- **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change**`GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3.
- **Migration** `AddItemSalePrice` — single nullable column add; no backfill (`Down()` drops it). **Applied** to the local DB (`dotnet ef database update` → Done).
- **Verified:** `dotnet build` clean (compile succeeded; the only earlier failure was the running dev exe holding a file lock, resolved by stopping it). Migration Up applied. Frontend `tsc --noEmit` clean. End-to-end runtime smoke (Swagger/UI) still to be run by the user.
- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true``Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value).
- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched.
- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly.