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:
2026-08-10 15:12:51 +05:30
parent d37824cecc
commit 8d5a05a419
63 changed files with 1539 additions and 136 deletions
+81
View File
@@ -0,0 +1,81 @@
"""Smoke test — UOM conversions are one-directional, and the API says so.
`UomConverter` looks up exactly one shape, `FromUom -> ToUom = item.BaseUomId`, and never
inverts a factor. `UpdateUomConversionsAsync` used to accept *any* pair, so saving the more
natural-reading `base -> BOX` produced a row that returned 200, appeared in the item detail
response, and was then silently invisible to every consumer — surfacing much later as
"no UOM conversion" 422 at GRN confirm or stage start, on an item that visibly had one.
* base -> other is rejected with 422 (the direction that used to save and then not work)
* other -> base is accepted
* a self-conversion and a base-as-source row are rejected
* a zero/negative factor is rejected (UomConverter divides unit cost by it)
* changing an item's base UOM while conversions exist is refused rather than orphaning them
python Backend/smoke/uom_direction.py
"""
from __future__ import annotations
import sys
from smoke_common import bootstrap
def main():
c, chk, args = bootstrap(__doc__)
print(f"API {args.api}")
item = c.get("/items?pageSize=1&status=Active").body["items"]
if not item:
sys.exit("FATAL: no active items.")
item = item[0]
item_id, base_uom = item["itemId"], item["baseUomId"]
uoms = c.get("/uoms?pageSize=50").body["items"]
other = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
if other is None:
sys.exit("FATAL: need at least 2 UOMs.")
print(f"item={item_id} baseUom={base_uom} otherUom={other}")
def put(conversions):
return c.put(f"/items/{item_id}/uom-conversions", {"conversions": conversions})
chk.section("1. The correct direction is accepted")
ok = put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
chk.status("other -> base", ok, 200)
if ok.status == 200:
chk.check("stored with the base UOM as target", ok.body["conversions"][0]["toUom"], base_uom)
chk.section("2. The reverse direction is rejected, not silently stored")
chk.status("base -> other", put([{"fromUom": base_uom, "toUom": other, "factor": 12}]), 422)
chk.section("3. Degenerate rows are rejected")
chk.status("self-conversion (other -> other)", put([{"fromUom": other, "toUom": other, "factor": 2}]), 422)
chk.status("zero factor", put([{"fromUom": other, "toUom": base_uom, "factor": 0}]), 422)
chk.status("negative factor", put([{"fromUom": other, "toUom": base_uom, "factor": -3}]), 422)
chk.section("4. Base UOM cannot be repointed while conversions exist")
# Restore a valid conversion first, so the guard has something to protect.
put([{"fromUom": other, "toUom": base_uom, "factor": 12}])
head = c.get(f"/items/{item_id}")
if head.status == 200:
body = head.body
moved = c.put(f"/items/{item_id}", {
"sku": body["sku"], "name": body["name"], "description": body.get("description"),
"categoryId": body["categoryId"], "subCategoryId": body.get("subCategoryId"),
"brandId": body.get("brandId"),
"baseUomId": other, # <- the repoint being guarded
"defaultVendorId": body.get("defaultVendorId"),
"stockNature": body["stockNature"], "trackingMode": body["trackingMode"],
"taxClass": body.get("taxClass"), "salePrice": body.get("salePrice"),
}, if_match=head.etag)
chk.status("change base UOM with conversions defined", moved, 422)
else:
chk.check("could read the item for the repoint test", head.status, 200)
return chk.finish("UOM-DIRECTION")
if __name__ == "__main__":
sys.exit(main())