Refactor production and stock tests to remove UOM dependency

- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
This commit is contained in:
2026-08-11 11:32:40 +05:30
parent d37824cecc
commit 15ddac178c
108 changed files with 1221 additions and 987 deletions
+10 -14
View File
@@ -54,15 +54,17 @@ export class GrnNewPage {
}
/**
* Item and UOM are each only a combobox when the row is NOT tied to a PO line
* (`line.poLineId` gates both cells identically in the source - a PO line renders them as
* plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than
* "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO
* mode - a row-wide check would false-positive on a PO line and select the wrong control.
* Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO
* line needs it set explicitly or submit blocks with "Select a UOM".
* Item is only a combobox when the row is NOT tied to a PO line (`line.poLineId` gates the
* cell in the source - a PO line renders it as plain text instead). Checked per-cell
* (td:nth(0)) rather than "row has any combobox", since the Bin/Hold-status cells always
* have one regardless of PO mode - a row-wide check would false-positive on a PO line and
* select the wrong control.
*
* td:nth(1) is still the UOM column, but it is now a read-only label showing the chosen
* item's base UOM: lines carry no unit of their own, so there is nothing to pick. The
* column was kept rather than removed, which is why every index below is unchanged.
*/
async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) {
async fillFirstLine(opts: { item?: string; qty: number; unitCost?: number }) {
const row = this.firstRow()
const cells = row.locator("td")
if (opts.item) {
@@ -71,12 +73,6 @@ export class GrnNewPage {
await selectOption(this.page, itemCombo, opts.item)
}
}
if (opts.uom) {
const uomCombo = cells.nth(1).getByRole("combobox")
if (await uomCombo.count()) {
await selectOption(this.page, uomCombo, opts.uom)
}
}
const numberInputs = row.locator('input[type="number"]')
await numberInputs.nth(0).fill(String(opts.qty)) // Qty
if (opts.unitCost !== undefined) {
+3 -5
View File
@@ -1,5 +1,5 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api"
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
@@ -16,7 +16,6 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
let sourceWarehouse: Warehouse
let destWarehouse: Warehouse
let vendor: Vendor
let uom: Uom
let rawItem: Item
let finishedItem: Item
let templateName: string
@@ -26,7 +25,6 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
seeder = new ApiSeeder(api)
sourceWarehouse = await seeder.firstWarehouse()
destWarehouse = await seeder.secondWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor("Chained Flow Vendor")
rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" })
finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" })
@@ -34,7 +32,7 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
const template = await seeder.createSingleStageTemplate({
rawItemId: rawItem.itemId,
finishedItemId: finishedItem.itemId,
uomId: uom.uomId,
})
templateName = template.name
})
@@ -50,7 +48,7 @@ test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
await grnNew.selectWarehouse(sourceWarehouse.name)
await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 })
await grnNew.fillFirstLine({ item: rawItem.name, qty: 100, unitCost: 20 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
+6 -8
View File
@@ -1,5 +1,5 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Vendor, Warehouse, Item, Uom } from "../support/api"
import { ApiSeeder, newApiContext, Vendor, Warehouse, Item } from "../support/api"
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
// GRN receiving flow (Backend/ERPCore/Controllers/GrnsController.cs, Frontend
@@ -10,14 +10,12 @@ test.describe("GRN", () => {
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "GRN Test Item" })
})
@@ -32,7 +30,7 @@ test.describe("GRN", () => {
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
await grnNew.selectWarehouse(warehouse.name)
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 10, unitCost: 50 })
await grnNew.fillFirstLine({ item: item.name, qty: 10, unitCost: 50 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
@@ -53,7 +51,7 @@ test.describe("GRN", () => {
vendorId: vendor.vendorId,
warehouseId: warehouse.warehouseId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 5,
unitPrice: 40,
})
@@ -76,7 +74,7 @@ test.describe("GRN", () => {
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
// Warehouse intentionally left unselected.
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 1, unitCost: 10 })
await grnNew.fillFirstLine({ item: item.name, qty: 1, unitCost: 10 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/new/)
@@ -87,7 +85,7 @@ test.describe("GRN", () => {
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 8,
unitCost: 12,
})
@@ -110,7 +108,7 @@ test.describe("GRN", () => {
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 3,
unitCost: 12,
})
+3 -5
View File
@@ -1,5 +1,5 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api"
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
// Production run lifecycle (Backend/ERPCore/Controllers/ProductionRunsController.cs,
@@ -12,7 +12,6 @@ test.describe("Production runs", () => {
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let rawItem: Item
let finishedItem: Item
let templateName: string
@@ -21,7 +20,6 @@ test.describe("Production runs", () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
rawItem = await seeder.createItem({ namePrefix: "PROD Raw Material" })
finishedItem = await seeder.createItem({ namePrefix: "PROD Finished Good" })
@@ -31,7 +29,7 @@ test.describe("Production runs", () => {
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: rawItem.itemId,
uomId: uom.uomId,
qty: 100,
unitCost: 20,
})
@@ -39,7 +37,7 @@ test.describe("Production runs", () => {
const template = await seeder.createSingleStageTemplate({
rawItemId: rawItem.itemId,
finishedItemId: finishedItem.itemId,
uomId: uom.uomId,
})
templateName = template.name
})
+2 -4
View File
@@ -1,5 +1,5 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api"
import { StockAdjustmentNewPage } from "../pages/StockPages"
// Stock adjustment flow (Backend/ERPCore/Controllers/StockAdjustmentsController.cs,
@@ -10,14 +10,12 @@ test.describe("Stock adjustments", () => {
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "Adjustment Test Item" })
@@ -25,7 +23,7 @@ test.describe("Stock adjustments", () => {
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 20,
unitCost: 30,
})
+2 -4
View File
@@ -1,5 +1,5 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { ApiSeeder, newApiContext, Warehouse, Item, Vendor } from "../support/api"
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
// Stock transfer flow (Backend/ERPCore/Controllers/StockTransfersController.cs, Frontend
@@ -11,7 +11,6 @@ test.describe("Stock transfers", () => {
let srcWarehouse: Warehouse
let destWarehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
@@ -19,7 +18,6 @@ test.describe("Stock transfers", () => {
seeder = new ApiSeeder(api)
srcWarehouse = await seeder.firstWarehouse()
destWarehouse = await seeder.secondWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "Transfer Test Item" })
@@ -27,7 +25,7 @@ test.describe("Stock transfers", () => {
warehouseId: srcWarehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 50,
unitCost: 15,
})
+11 -9
View File
@@ -118,14 +118,14 @@ export class ApiSeeder {
}
/** Direct (no-PO) GRN, confirmed immediately, so the item has on-hand stock to test against. */
async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; qty: number; unitCost: number }) {
const grn = await this.post<{ grnId: number }>("/grns", {
vendorId: opts.vendorId,
warehouseId: opts.warehouseId,
lines: [
{
itemId: opts.itemId,
uomId: opts.uomId,
qty: opts.qty,
unitCost: opts.unitCost,
discountPct: 0,
@@ -139,14 +139,14 @@ export class ApiSeeder {
}
/** Direct GRN with the line held for inspection, confirmed - gives the detail page a line with Release/Reject actions. */
async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; qty: number; unitCost: number }) {
const grn = await this.post<{ grnId: number; lines: { grnLineId: number }[] }>("/grns", {
vendorId: opts.vendorId,
warehouseId: opts.warehouseId,
lines: [
{
itemId: opts.itemId,
uomId: opts.uomId,
qty: opts.qty,
unitCost: opts.unitCost,
discountPct: 0,
@@ -159,13 +159,13 @@ export class ApiSeeder {
return grn
}
async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; uomId: number; qty: number; unitPrice: number }) {
async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; qty: number; unitPrice: number }) {
return this.post<{ poId: number; docNo: string }>("/purchase-orders", {
vendorId: opts.vendorId,
lines: [
{
itemId: opts.itemId,
uomId: opts.uomId,
warehouseId: opts.warehouseId,
qty: opts.qty,
unitPrice: opts.unitPrice,
@@ -181,7 +181,7 @@ export class ApiSeeder {
* smallest graph ProductionGraphValidator accepts (Backend/ERPCore/Services/Production/
* ProductionGraphValidator.cs: exactly one terminal, terminal has exactly one item output).
*/
async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number; uomId: number }) {
async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number }) {
const suffix = uniqueSuffix()
return this.post<{ templateId: number; code: string; name: string }>("/production-templates", {
code: `E2E-TPL-${suffix}`,
@@ -194,8 +194,10 @@ export class ApiSeeder {
posX: 0,
posY: 0,
fieldDefs: [],
inputs: [{ source: "Stock", itemId: opts.rawItemId, uomId: opts.uomId, qtyPerBatch: 1 }],
outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", uomId: opts.uomId, qtyPerBatch: 1 }],
// No unit on either row: an input is a pack count unless it says otherwise, and the
// terminal output takes its unit from the finished item.
inputs: [{ source: "Stock", itemId: opts.rawItemId, qtyPerBatch: 1 }],
outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", qtyPerBatch: 1 }],
},
],
edges: [],