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
+3 -1
View File
@@ -74,6 +74,7 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
- [ ] Create/update DTOs exclude server-controlled fields (`status`, ids, timestamps)
- [ ] Deactivate — not delete — referenced masters (FR-MD-08); hard delete blocked → `MASTER_IN_USE`
- [ ] Nested/reference writes validate the target exists and is active
- [ ] `Item.salePrice` is a **legitimately client-supplied** field (a deliberate exception to B.6's over-posting list) — validated `>= 0` server-side, nullable. It is **Sales-only** (never enters GRN/FIFO/ledger), so unlike GRN `unitCost` it has **no** inventory-value or costing impact; the fixed/stock-value choice is frontend UX (`docs/11 §2.1`).
### C.2 Procurement (Requisition / RFQ / PO / Purchase Return)
- [ ] PO totals computed **server-side** from lines (never trust client totals)
@@ -86,7 +87,8 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin
- [ ] `unitCost` **defaults to the PO line price**; a per-line override **is now permitted** *(decision revised 2026-07-20 — was "locked, client cost ignored")*. When an override is entered it is used, and the PO price is snapshotted (`poUnitPrice`) so a **`priceVariance` is recorded** against it for review. Rationale: one PO legitimately spans batches received at different prices; the variance trail (plus the audit log) is the compensating control that replaces the old hard block.
- [ ] **Derived figures stay server-computed**`netUnitCost`/`receivedValue`/`vatAmount`/`lineTotal` are never accepted from the client, so the client cannot inflate stock value except by an *auditable* unit-cost override. Discount reduces inventory cost; **VAT is recoverable and never enters stock value**.
- [ ] Direct GRN (no PO) remains the higher-scrutiny path where cost is entered with no PO to compare against → review flag + audit (**AR-04**)
- [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE`
- [ ] **Off-PO lines on a PO-based GRN** (`poLineId: null`, 2026-07-22) are the **same exposure class as AR-04** — cost is entered with no PO line to compare against, and `OVER_RECEIPT_TOLERANCE` does not apply to them. Treat them with the direct-receipt scrutiny (review flag + audit); they do not touch PO balances.
- [ ] Over-receipt tolerance enforced server-side → `OVER_RECEIPT_TOLERANCE` (PO-linked lines only; off-PO lines have no PO qty to check)
- [ ] On-hold stock is not issuable (FR-WH-07); expired batch blocked
### C.4 Stock Core (FIFO / Ledger)
+9 -4
View File
@@ -118,7 +118,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
### B.3.1 Master Data (FR-MD)
| ID | Requirement | Pri |
|---|---|---|
| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor. | M |
| FR-MD-01 | Maintain **Item master**: SKU (unique), name, description, category (+ optional subcategory), optional brand, **stock nature** (Stocked/NonStocked/Service), tracking mode (None/Batch/Serial), status, tax class, default vendor, **optional fixed sale price** (nullable; Sales-only — never enters costing/GRN/FIFO; `null` ⇒ item is sold at its stock/FIFO value). | M |
| FR-MD-02 | Maintain **UOM master** with base UOM per item and **conversion factors** (purchase→stock→base). | M |
| FR-MD-03 | Convert quantities between UOMs on every transaction; store base-UOM quantity in the ledger. | M |
| FR-MD-04 | Maintain **item categories with one optional subcategory level**. An item references a category (required) and a subcategory (optional) that must belong to it. Deeper nesting is not supported. | S |
@@ -146,7 +146,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
### B.3.3 Goods Receipt (FR-GRN)
| ID | Requirement | Pri |
|---|---|---|
| FR-GRN-01 | Create **GRN** against an approved PO, defaulting lines/quantities from open PO lines. | M |
| FR-GRN-01 | Create **GRN** against an approved PO, defaulting lines/quantities from open PO lines. **Additional lines for items not on the PO are permitted** — a line with no `po_line_id` is received like a direct receipt (entered cost, no over-receipt check) and does not affect PO line balances. Off-PO lines are a review surface (see 02-SECURITY C.3). | M |
| FR-GRN-02 | Support **GRN without PO** (direct/emergency) by permission, flagged for review. | S |
| FR-GRN-03 | Support **over/under-receipt tolerances** (per item or global); warn or block beyond tolerance. | S |
| FR-GRN-04 | Capture **batch + expiry** and/or **serial numbers** for tracked items on receipt. | M |
@@ -250,6 +250,8 @@ Adjustment: Damage, Theft/Loss, Count Variance, Expiry Write-off, System Correct
| 11 | Category hierarchy depth | **Resolved:** dedicated `SUBCATEGORY` table, exactly two levels; `CATEGORY.parent_id` dropped. Item carries both FKs (subcategory nullable). Arbitrary nesting is not coming back. |
| 12 | Item types / variants | **Resolved:** the `ItemType` **enum** was replaced by an **unreferenced master list**; Stocked/NonStocked/Service survives as `stock_nature`. Values are **SKU-encoded only** — no value table, no item link, no product-variation model (Part C.9 records the accepted trade-off). |
| 13 | Product-config authorization | **Open:** `PUT /product-config` is gated by the door policy only, like every other endpoint. A `CONFIG_MANAGE` permission is reserved for when per-endpoint RBAC lands (decision #6). Until then any ERP-admitted user can flip the flags. |
| 14 | Item sale price (fixed vs stock value) | **Resolved (2026-07-22):** a single **nullable** `ITEM.sale_price``NULL` ⇒ sell at stock/FIFO value, a value ⇒ fixed price. **Sales-only** (never touches GRN/FIFO/ledger). No `price_mode` enum; the create-time fixed/stock toggle is frontend UX that requires a price per generated variant when "fixed" is chosen (Part C.9). |
| 15 | Off-PO lines on a PO-based GRN | **Resolved (2026-07-22):** allowed. `GRN_LINE.po_line_id` is nullable; a null line on a PO-based GRN is received like a direct receipt (entered cost, no over-receipt check) and does not touch PO balances. Same cost-entry/fraud surface as GRN-without-PO (AR-04) — flagged for review, not blocked (02-SECURITY C.3). |
---
@@ -267,7 +269,8 @@ UOM(uom_id PK, name)
UOM_CONVERSION(conversion_id PK, item_id FK→ITEM, from_uom FK→UOM, to_uom FK→UOM, factor)
ITEM(item_id PK, sku, name, category_id FK→CATEGORY, subcategory_id FK→SUBCATEGORY [nullable],
brand_id FK→BRAND [nullable], base_uom_id FK→UOM,
default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class, status)
default_vendor_id FK→VENDOR, stock_nature, tracking_mode, tax_class,
sale_price [nullable], status) -- sale_price: Sales-only selling price; NULL ⇒ sell at stock (FIFO) value
ITEM_REORDER(reorder_id PK, item_id FK→ITEM, warehouse_id FK→WAREHOUSE, reorder_point, reorder_qty)
VENDOR(vendor_id PK, code, name, terms, tax_reg, currency, status)
WAREHOUSE(warehouse_id PK, code, name)
@@ -303,8 +306,9 @@ PURCHASE_RETURN_LINE(return_line_id PK, return_id FK→PURCHASE_RETURN,
```
GRN(grn_id PK, doc_no, po_id FK→PURCHASE_ORDER, vendor_id FK→VENDOR,
warehouse_id FK→WAREHOUSE, status, created_by FK→USER, created_at)
GRN_LINE(grn_line_id PK, grn_id FK→GRN, po_line_id FK→PO_LINE, item_id FK→ITEM, uom_id FK→UOM,
GRN_LINE(grn_line_id PK, grn_id FK→GRN, po_line_id FK→PO_LINE [nullable], item_id FK→ITEM, uom_id FK→UOM,
bin_id FK→BIN, batch_id FK→BATCH, qty, unit_cost, received_value, hold_status)
-- po_line_id nullable: NULL for a direct receipt OR an off-PO line added to a PO-based GRN (FR-GRN-01)
```
## C.4 Batch / Serial
@@ -362,6 +366,7 @@ Note: `USER_ROLE` from the original placeholder sketch was dropped — a user ha
## C.9 Modeling notes (load-bearing)
- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy.
- *Accepted trade-off (a decision, not an oversight):* the backend cannot answer "list all blue items", cannot filter or report by colour/size, and cannot validate that a SKU's segments correspond to real item types. Renaming an item type (`Color``Colour`) does **not** touch existing SKUs, which keep their old segments — the two are permanently decoupled the moment an item is created. If value-level querying is ever needed, an `ITEM_TYPE_VALUE` table plus a link table can be added additively, but existing SKUs will not be back-fillable without parsing them by hand.
- **Sale price is a per-item scalar, not a variant/price table.** Because each "variant" is its own `ITEM` row (above), the optional selling price lives directly on `ITEM.sale_price` (nullable). `NULL` means "use stock value" — Sales values the item at its FIFO stock cost at sale time (FR-STK-04 / `STOCK_LAYER`); a value is a fixed selling price. It is **Sales-only**: it never participates in GRN, FIFO layering, or the stock ledger, so receipt/costing behaviour is identical whether the item is fixed-priced or not. The create-time "fixed price vs use stock value" choice is a **frontend UX toggle** — the contract is simply the nullable column, and the item builder requires a price on every generated variant when the user picks fixed pricing.
- **Two-level categories.** `CATEGORY` no longer self-nests; `SUBCATEGORY` is the single optional level below it. An item stores both FKs rather than pointing only at the deepest node, so the parent is never inferred or lost. A subcategory cannot be reparented (it would silently invalidate the category of every item referencing it) — deactivate and recreate instead.
- **Product config is a singleton, and only two of its flags are enforceable.** `subcategories_enabled` / `brands_enabled` gate item writes (`CONFIG_DISABLED`, 422). `item_types_enabled` is **advisory only** — since items carry no item-type reference, there is nothing on a write to reject; the frontend honours it by hiding the builder's type section. Reads are never gated, so existing data stays readable after a flag is switched off.
- **FIFO = two structures.** `STOCK_LAYER` answers valuation ("what's on hand and at what cost"); `STOCK_LEDGER` answers history ("what moved, when, by whom"). Layers are keyed per item **per warehouse**.
+16 -5
View File
@@ -164,6 +164,8 @@ docs/10 C.9): every write below forwards to AuthHex's new `/api/role` functions
### 2.1 Items
> **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9).
>
> **`salePrice` added (nullable, 2026-07-22).** Every item body below carries `salePrice` (`number|null`). It is the **Sales-only** fixed selling price: `null` ⇒ the item is sold at its stock/FIFO value; a value ⇒ fixed price. It never affects GRN/FIFO/ledger. On write it is optional; when supplied it must be `>= 0` (else `400` validation). The item builder's "fixed price / use stock value" toggle is UI-only — the contract is just the nullable field.
#### `GET /items`
Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandId`, `trackingMode` (`None|Batch|Serial`), + paging.
@@ -171,7 +173,8 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI
```json
{ "items": [ { "itemId": 1001, "sku": "ITM-1001", "name": "Steel Bolt M8x40",
"categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5,
"stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD", "status": "Active" } ],
"stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
"salePrice": 12.5000, "status": "Active" } ],
"pagination": { "page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1 } }
```
@@ -181,7 +184,8 @@ Query: `q`, `status` (`Active|Inactive`), `categoryId`, `subCategoryId`, `brandI
"description": "Grade 8.8 zinc-plated hex bolt", "categoryId": 12, "subCategoryId": 30,
"brandId": 2, "baseUomId": 1,
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "Batch", "taxClass": "STD",
"status": "Active", "reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"salePrice": 12.5000, "status": "Active",
"reorder": [ { "warehouseId": 1, "reorderPoint": 500, "reorderQty": 2000 } ],
"conversions": [ { "conversionId": 33, "fromUom": 7, "toUom": 1, "factor": 12 } ],
"createdAt": "2026-06-01T08:00:00Z", "updatedAt": "2026-07-01T10:15:00Z" }
```
@@ -192,14 +196,14 @@ The `sku` is **generated by the client** (it encodes the chosen item-type values
```json
{ "sku": "ITM-1002", "name": "Steel Nut M8", "description": "Grade 8 zinc-plated hex nut",
"categoryId": 12, "subCategoryId": 30, "brandId": 2, "baseUomId": 1, "defaultVendorId": 5,
"stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD" }
"stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD", "salePrice": 3.2500 }
```
**201 Created**`Location: /api/v1/items/1002`
```json
{ "itemId": 1002, "sku": "ITM-1002", "name": "Steel Nut M8", "categoryId": 12,
"subCategoryId": 30, "brandId": 2, "baseUomId": 1,
"defaultVendorId": 5, "stockNature": "Stocked", "trackingMode": "None", "taxClass": "STD",
"status": "Active", "createdAt": "2026-07-07T09:30:00Z" }
"salePrice": 3.2500, "status": "Active", "createdAt": "2026-07-07T09:30:00Z" }
```
`400``code: SKU_DUPLICATE` if SKU exists.
`422``code: CONFIG_DISABLED` if `subCategoryId` is sent while subcategories are disabled, or `brandId` while brands are disabled (§2.8).
@@ -530,11 +534,18 @@ Against a PO (lines default from open PO lines) or direct (`poId: null`, by perm
{ "poId": 342, "warehouseId": 1,
"lines": [ { "poLineId": 900, "itemId": 1001, "uomId": 1, "binId": 45, "qty": 5000,
"unitCost": 12.50, "discountPct": 10, "vatPct": 18, "holdStatus": "OnHold",
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } } ] }
"batch": { "batchNo": "B-2607", "expiryDate": "2028-07-01" } },
{ "poLineId": null, "itemId": 1050, "uomId": 1, "qty": 20,
"unitCost": 8.00, "holdStatus": "Available" } ] }
```
`discountPct`/`vatPct` optional (default 0, range 0100). `unitCost` on a **PO line** is an optional
override: 0/omitted uses the PO price; a value wins and a variance is recorded (02-SECURITY C.3, revised).
On a direct receipt `unitCost` is required.
**Off-PO lines (`poLineId: null`) are allowed even on a PO-based GRN** (2026-07-22, FR-GRN-01) — the second
line above receives an item that is not on the PO. Such a line behaves exactly like a direct-receipt line:
`unitCost` is required, `OVER_RECEIPT_TOLERANCE` does **not** apply (there is no PO qty to check), and no PO
line balance is touched. The inline "create new item" UI simply calls `POST /items` (§2.1) first, then adds
the returned item as an off-PO line.
**201 Created** — status `Draft`. All derived figures are **server-computed**:
`netUnitCost = unitCost × (1 discountPct/100)`, `receivedValue = qty × netUnitCost` (after discount,
**before** VAT — this is the stock value), `vatAmount = receivedValue × vatPct/100`,
+3 -1
View File
@@ -143,9 +143,11 @@ Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Typ
- **SKU generation stays client-side** (`buildVariantSku`) and is the *only* record of which colour/size an item is; the server only uniqueness-checks it. **Nothing can query items by colour** — accepted (`docs/10 Part C.9`).
- **`remove()``updateStatus(id, "Inactive")`** everywhere. There are no `DELETE` endpoints on any master (FR-MD-08); the lists show a Status column and Deactivate/Activate.
- **`initialQty` is gone** from the builder — the Item contract has no such field and there is no initial-receipt flow. Stock arrives via a GRN.
- **Sale-price toggle on the builder (2026-07-22).** A "Fixed price / Use stock value" toggle sits on `/dashboard/products/new`. **Use stock value** (default) sends `salePrice: null` on every created item (sold at FIFO value). **Fixed price** reveals a top "fix value" input that pre-fills a per-variant `Sale price` column; each row is editable, and submit is blocked until **every** generated variant has a price `> 0`. The toggle is **frontend-only** — the contract is just the nullable `salePrice` field (`docs/11 §2.1`); it is Sales-only and never affects GRN/costing. The non-transactional create loop still applies — each variant's `salePrice` rides its own `POST /items`.
- **Product Configuration** (`app/dashboard/products/settings`, `GET`/`PUT /product-config`) — only **3** of the original design's ~13 toggles exist. `subcategoriesEnabled`/`brandsEnabled` are server-enforced (`CONFIG_DISABLED`); **`itemTypesEnabled` is advisory** and this app is what honours it (it hides the builder's type section). The UI states that distinction on the screen rather than implying a guarantee.
- **Non-transactional create loop:** the builder's per-row `itemsApi.create()` has no transaction — a `SKU_DUPLICATE` on row 7 of 12 leaves 6 items created. The error message now says how many landed rather than implying nothing happened. A transactional bulk-create endpoint would be the real fix.
- **GRN edit/delete removed** — the API has no `PUT`/`DELETE` for a GRN; receipts are corrected by reversing documents (FR-X-05).
- **GRN off-PO items + inline item create (2026-07-22).** On `/dashboard/receiving/grn/new`, "Add line" is available in **both** PO and direct mode — an added line in PO mode has `poLineId: null` and receives an item not on the PO (editable item/UOM dropdowns, `unitCost` required). A **"New item"** button opens `/dashboard/products/new` in a **new browser tab** (`window.open`, the first such pattern in the app), and a **refresh icon** re-pulls `GET /items?status=Active` so the newly created item is selectable **without** reloading the screen and losing the in-progress GRN. Server treats off-PO lines as direct receipts (no over-receipt check) — `docs/11 §4.1`.
- **RFQ invited-vendors is not persisted** — `POST /rfqs` validates `vendorIds` then discards them, so the list/detail screens show quotations received instead of vendors invited.
- **Known gap — serial numbers:** FR-GRN-04 requires capturing serials on receipt, but `CreateGrnLineInput` has no such field (only `batch`). The UI does not collect them rather than silently discarding them. Needs a backend change to honour the requirement.
@@ -159,7 +161,7 @@ Vendors, Items, Categories, Subcategories, UOM, Warehouses, Brands, and Item Typ
**Client-side (UX only — safe to check locally):** purely input-level facts the browser already has.
- Required fields present.
- Format: SKU pattern, numeric fields numeric, date format, positive integers.
- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`.
- Range/bounds: `qty > 0`, `unitPrice >= 0`, `factor > 0`. Item `salePrice` is a client-supplied number — the builder requires `> 0` per variant in fixed mode; the server only checks `>= 0` on a supplied value (`docs/11 §2.1`).
- Simple cross-field input rules: transfer `destWarehouseId != srcWarehouseId`.
- Enum membership via constrained dropdowns (`stockNature` — ex-`itemType`, `trackingMode`, `countType`, `holdStatus`). Note the **Item Type** dropdown is *not* in this category: it's server data (`GET /item-types`), not an enum.