Add smoke tests for UOM conversions and enhance frontend UOM management
- Implemented smoke tests for UOM directionality, ensuring conversions are one-directional and correctly validated. - Added tests for receiving and selling items in different UOMs, verifying correct quantity handling and error responses. - Created a UOM conversions panel in the frontend to allow users to manage UOM conversions for items. - Introduced hooks for allowed UOMs to optimize fetching and caching of UOM data for document line forms. - Developed utility functions for consistent UOM formatting and conversion handling across the application.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
AllowedUom,
|
||||
CreateItemRequest,
|
||||
Item,
|
||||
ItemListItem,
|
||||
@@ -60,10 +61,20 @@ export const itemsApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the item's conversions. Every row must be `<other> → baseUomId` with a factor
|
||||
* greater than zero; the reverse direction, a self-conversion, or a row from the base UOM
|
||||
* is rejected with 422 (the engine only ever looks up `<other> → base` and never inverts).
|
||||
*/
|
||||
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
|
||||
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
/** The UOMs this item can be transacted in — base UOM first, then each conversion source. */
|
||||
allowedUoms(itemId: number): Promise<AllowedUom[]> {
|
||||
return apiRequest<AllowedUom[]>(`/items/${itemId}/uoms`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// The single place the UI resolves and renders units of measure.
|
||||
//
|
||||
// Before this module, ~10 screens each re-declared their own inline `uoms.find(...)` under
|
||||
// three different names, with two different unknown-UOM fallbacks (`#12` vs `UOM 12`), and
|
||||
// there was no quantity formatter anywhere. Everything unit-shaped goes through here now.
|
||||
//
|
||||
// The one rule these helpers encode: **conversion toward base is authoritative.** The server
|
||||
// stores stock, layers and the ledger exclusively in an item's base UOM, and resolves the
|
||||
// base quantity when a document line is saved. `toBase` below exists to *preview* that for
|
||||
// the user at entry time; it is never the source of what gets posted.
|
||||
import { AllowedUom, Uom } from "@/types/master-data"
|
||||
|
||||
/** Shown when a uomId has no matching row — one fallback across the whole app. */
|
||||
const UNKNOWN_UOM = "—"
|
||||
|
||||
/** Quantities are `decimal(18,4)` server-side; trailing zeros are noise in a table. */
|
||||
const QTY_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
|
||||
/** `12` -> "PCS". Accepts any list with `uomId`/`name`, so `Uom[]` and `AllowedUom[]` both work. */
|
||||
export function uomName(
|
||||
uomId: number | null | undefined,
|
||||
uoms: readonly { uomId: number; name: string }[],
|
||||
): string {
|
||||
if (uomId === null || uomId === undefined || uomId === 0) return UNKNOWN_UOM
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? UNKNOWN_UOM
|
||||
}
|
||||
|
||||
/** `1234.5` -> "1,234.5". Quantity-specific: unlike `formatAmount` it does not force 2dp. */
|
||||
export function formatQtyValue(qty: number | null | undefined): string {
|
||||
if (qty === null || qty === undefined || Number.isNaN(qty)) return "—"
|
||||
return QTY_FORMATTER.format(qty)
|
||||
}
|
||||
|
||||
/**
|
||||
* `(24, 12, uoms)` -> "24 PCS". The formatter every screen showing a quantity should use —
|
||||
* a bare number leaves the user guessing which unit a figure is in.
|
||||
*/
|
||||
export function formatQty(
|
||||
qty: number | null | undefined,
|
||||
uomId: number | null | undefined,
|
||||
uoms: readonly { uomId: number; name: string }[],
|
||||
): string {
|
||||
const value = formatQtyValue(qty)
|
||||
const unit = uomName(uomId, uoms)
|
||||
return unit === UNKNOWN_UOM ? value : `${value} ${unit}`
|
||||
}
|
||||
|
||||
/** Convenience for stock screens, whose DTOs carry `baseUomName` directly from the server. */
|
||||
export function formatQtyWithName(qty: number | null | undefined, uomName: string | null | undefined): string {
|
||||
const value = formatQtyValue(qty)
|
||||
return uomName ? `${value} ${uomName}` : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an entered quantity to the item's base UOM, matching the server's arithmetic
|
||||
* (multiply by the factor, round to 4dp). Display only — the authoritative base quantity is
|
||||
* the one the server resolves and snapshots when the line is saved.
|
||||
*/
|
||||
export function toBase(qty: number, factor: number): number {
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(factor)) return 0
|
||||
return Math.round(qty * factor * 10_000) / 10_000
|
||||
}
|
||||
|
||||
/**
|
||||
* The hint rendered beside a quantity input: `"= 24 PCS"` when the chosen unit is not the
|
||||
* item's base, and `null` when it is (a "= 24 PCS" next to "24 PCS" is just noise).
|
||||
*
|
||||
* Making the conversion visible at entry is the point — previously a user only discovered a
|
||||
* unit mismatch as a 422 when they tried to post the finished document.
|
||||
*/
|
||||
export function basePreview(
|
||||
qty: number | null | undefined,
|
||||
uomId: number | null | undefined,
|
||||
allowed: readonly AllowedUom[],
|
||||
): string | null {
|
||||
if (!qty || !uomId) return null
|
||||
const selected = allowed.find((u) => u.uomId === uomId)
|
||||
const base = allowed.find((u) => u.isBase)
|
||||
if (!selected || !base || selected.isBase) return null
|
||||
return `= ${formatQtyValue(toBase(qty, selected.factor))} ${base.name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Falls back to the global UOM list while an item's allowed units are still loading (or when
|
||||
* no item is chosen yet), so a picker never renders empty. Once `allowed` arrives it wins —
|
||||
* that narrowing is the whole reason the endpoint exists.
|
||||
*/
|
||||
export function pickerOptions(allowed: readonly AllowedUom[] | undefined, all: readonly Uom[]): { uomId: number; name: string }[] {
|
||||
if (allowed && allowed.length > 0) return allowed.map((u) => ({ uomId: u.uomId, name: u.name }))
|
||||
return all.map((u) => ({ uomId: u.uomId, name: u.name }))
|
||||
}
|
||||
Reference in New Issue
Block a user