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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -25,6 +25,11 @@ SCRIPTS = [
|
||||
("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"),
|
||||
("M5 terminal receipt + cost pool", "m5_receipt.py"),
|
||||
("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"),
|
||||
# UOM engine. These run after the manufacturing scripts because they reuse the
|
||||
# SMOKE-PRD warehouse m4 creates, and they drain it before seeding their own stock.
|
||||
("UOM conversion direction is enforced", "uom_direction.py"),
|
||||
("UOM non-base sales consume converted qty", "uom_sales_nonbase.py"),
|
||||
("UOM cross-unit GRN against a PO", "uom_grn_po_cross.py"),
|
||||
]
|
||||
|
||||
SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed")
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Smoke test — receiving in a different UOM from the one ordered.
|
||||
|
||||
`GrnService` compared the GRN line's entered quantity against `poLine.Qty - poLine.QtyReceived`
|
||||
with no conversion, so a PO for 10 BOX receiving a legitimate 120 base units was rejected
|
||||
outright with OVER_RECEIPT_TOLERANCE — a user-visible false failure. It then accrued the GRN's
|
||||
quantity into `poLine.QtyReceived` (a PO-UOM field), and the close condition consumed that
|
||||
mixed-unit value, so a PO could close early or never close.
|
||||
|
||||
Both sides now run on the base pair (`QtyBase` / `QtyReceivedBase`), with `QtyReceived` kept
|
||||
as a denormalized display figure only.
|
||||
|
||||
* a receipt in base UOM against a PO raised in BOX is ACCEPTED
|
||||
* the FIFO layer and ledger record the base quantity
|
||||
* `qtyReceivedBase` accrues correctly and the PO reaches FullyReceived
|
||||
* over-receipt beyond tolerance is still rejected, now measured in base units
|
||||
|
||||
python Backend/smoke/uom_grn_po_cross.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, ensure_vendor
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
FACTOR = 12
|
||||
ORDER_BOXES = 10 # -> 120 base units
|
||||
RECEIVE_BASE = 120 # the whole order, expressed in base units
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
|
||||
if i["stockNature"] == "Stocked" and i["trackingMode"] == "None"), None)
|
||||
if item is None:
|
||||
sys.exit("FATAL: need a Stocked, untracked item.")
|
||||
base_uom = item["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if box_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs.")
|
||||
|
||||
vendor = ensure_vendor(c)
|
||||
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
|
||||
|
||||
chk.section("1. Conversion + a PO raised in BOX")
|
||||
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("define BOX -> base conversion", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
po = c.post("/purchase-orders", {
|
||||
"vendorId": vendor,
|
||||
"lines": [{"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": ORDER_BOXES, "unitPrice": 60, "tax": 0}],
|
||||
})
|
||||
chk.status("create the PO in BOX", po, 201)
|
||||
if po.status != 201:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
po_line = po.body["lines"][0]
|
||||
chk.check("PO line keeps the ordered qty in BOX", float(po_line["qty"]), float(ORDER_BOXES))
|
||||
chk.check("PO line snapshots the base quantity", float(po_line["qtyBase"]), float(ORDER_BOXES * FACTOR))
|
||||
chk.check("PO line snapshots the factor", float(po_line["conversionFactor"]), float(FACTOR))
|
||||
|
||||
drain_stock(c, wh)
|
||||
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
|
||||
chk.section("2. Receiving the order in BASE units is accepted")
|
||||
grn = c.post("/grns", {
|
||||
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
|
||||
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
|
||||
"uomId": base_uom, # <- different UOM from the PO
|
||||
"qty": RECEIVE_BASE, "unitCost": 5, "discountPct": 0, "vatPct": 0}],
|
||||
})
|
||||
# This is the assertion that fails on the old code: it returned 422 OVER_RECEIPT_TOLERANCE.
|
||||
chk.status("GRN in base UOM against a BOX purchase order", grn, 201)
|
||||
if grn.status != 201:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
|
||||
chk.status("confirm the GRN", confirmed, 200)
|
||||
if confirmed.status != 200:
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
chk.check("on-hand rose by the base quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before + RECEIVE_BASE)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=GRN&sourceDocId={grn.body['grnId']}&pageSize=50").body["items"]
|
||||
chk.check("one GRN ledger row", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the received base quantity", float(rows[0]["qtyBase"]), float(RECEIVE_BASE))
|
||||
|
||||
chk.section("3. The PO closes on the base pair")
|
||||
reread = c.get(f"/purchase-orders/{po.body['poId']}")
|
||||
chk.status("re-read the PO", reread, 200)
|
||||
if reread.status == 200:
|
||||
rl = reread.body["lines"][0]
|
||||
chk.check("qtyReceivedBase accrued in base units", float(rl["qtyReceivedBase"]), float(RECEIVE_BASE))
|
||||
chk.check("qtyReceived shown back in the PO's own UOM", float(rl["qtyReceived"]), float(ORDER_BOXES))
|
||||
chk.check("PO is FullyReceived", reread.body["status"], "FullyReceived")
|
||||
|
||||
chk.section("4. Over-receipt is still rejected, measured in base")
|
||||
over = c.post("/grns", {
|
||||
"vendorId": vendor, "warehouseId": wh, "poId": po.body["poId"],
|
||||
"lines": [{"poLineId": po_line["poLineId"], "itemId": item["itemId"],
|
||||
"uomId": base_uom, "qty": RECEIVE_BASE, "unitCost": 5,
|
||||
"discountPct": 0, "vatPct": 0}],
|
||||
})
|
||||
chk.status("receiving the whole order again", over, 422, "OVER_RECEIPT_TOLERANCE")
|
||||
|
||||
return chk.finish("UOM-GRN-PO")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Smoke test — selling in a non-base UOM consumes the converted quantity.
|
||||
|
||||
This is the regression test for the UOM engine's worst defect. `SalesPostingService`
|
||||
injected `IUomConverter` and never called it: `PostAsync` fed the *entered* line quantity
|
||||
straight into `IFifoCostingService.ConsumeAsync`, whose contract is base UOM only. Selling
|
||||
2 BOX of a 12-per-box item therefore removed 2 base units instead of 24 and wrote
|
||||
`StockLedger.QtyBase = 2` into a column defined as base — overstating stock, understating
|
||||
COGS, and drifting the ledger's running balance away from the layer sum.
|
||||
|
||||
`m4b_uom_conversion.py` covered exactly the same hazard on the *production* path, which is
|
||||
why that path was correct and this one was not. This script closes the gap:
|
||||
|
||||
* an invoice line in a non-base UOM consumes qty x factor base units
|
||||
* the ledger records the BASE quantity
|
||||
* the line still reports the ENTERED qty and UOM, so the printed document says "2 BOX"
|
||||
* the pre-post check reports the shortfall in base units (it compared entered vs base
|
||||
on-hand before, and answered "can post" when it could not)
|
||||
* the same holds for a sales slip, which shares PostAsync
|
||||
* a UOM the item has no conversion for is refused at line creation, not at post
|
||||
|
||||
python Backend/smoke/uom_sales_nonbase.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from smoke_common import bootstrap, drain_stock, seed_costed_stock
|
||||
|
||||
WAREHOUSE_CODE = "SMOKE-PRD"
|
||||
FACTOR = 12 # 1 BOX = 12 base units
|
||||
SELL_BOXES = 2 # -> 24 base units
|
||||
SEED_BASE = 100 # base units on hand before selling
|
||||
UNIT_COST = 5.0
|
||||
|
||||
|
||||
def main():
|
||||
c, chk, args = bootstrap(__doc__)
|
||||
print(f"API {args.api}")
|
||||
|
||||
wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]
|
||||
if w["code"] == WAREHOUSE_CODE), None)
|
||||
if wh is None:
|
||||
sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).")
|
||||
|
||||
item = next((i for i in c.get("/items?pageSize=20&status=Active").body["items"]
|
||||
if i["stockNature"] == "Stocked"), None)
|
||||
if item is None:
|
||||
sys.exit("FATAL: need a Stocked item.")
|
||||
base_uom = item["baseUomId"]
|
||||
|
||||
uoms = c.get("/uoms?pageSize=50").body["items"]
|
||||
box_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None)
|
||||
if box_uom is None:
|
||||
sys.exit("FATAL: need at least 2 UOMs to test conversion.")
|
||||
|
||||
customer = c.get("/customers?pageSize=1").body["items"]
|
||||
if not customer:
|
||||
sys.exit("FATAL: no customers seeded.")
|
||||
customer_id = customer[0]["customerId"]
|
||||
|
||||
print(f"item={item['itemId']} baseUom={base_uom} boxUom={box_uom} factor={FACTOR}")
|
||||
|
||||
# --- fixtures ---------------------------------------------------------
|
||||
chk.section("1. Conversion + known on-hand")
|
||||
conv = c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
chk.status("define BOX -> base conversion", conv, 200)
|
||||
if conv.status != 200:
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
allowed = c.get(f"/items/{item['itemId']}/uoms")
|
||||
chk.status("GET /items/{id}/uoms", allowed, 200)
|
||||
if allowed.status == 200:
|
||||
ids = [u["uomId"] for u in allowed.body]
|
||||
chk.check("allowed UOMs are base + the conversion source", sorted(ids), sorted([base_uom, box_uom]))
|
||||
chk.check("base UOM is flagged and listed first", allowed.body[0]["isBase"], True)
|
||||
|
||||
drain_stock(c, wh)
|
||||
seed_costed_stock(c, wh, [(item["itemId"], base_uom, SEED_BASE, UNIT_COST)])
|
||||
before = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
chk.check(f"on-hand seeded to {SEED_BASE} base units", before, float(SEED_BASE))
|
||||
|
||||
on_hand = c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body
|
||||
chk.check("stock read is labelled with the base UOM", on_hand["baseUomId"], base_uom)
|
||||
chk.check("stock read carries the base UOM name", bool(on_hand["baseUomName"]), True)
|
||||
|
||||
# --- invoice in BOX ---------------------------------------------------
|
||||
chk.section("2. An invoice line entered in BOX")
|
||||
expected_base = SELL_BOXES * FACTOR # 2 x 12 = 24
|
||||
|
||||
inv = c.post("/sales-invoices", {
|
||||
"customerId": customer_id,
|
||||
"warehouseId": wh,
|
||||
"invoiceType": "B2C",
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
chk.status("create the invoice", inv, 201)
|
||||
if inv.status != 201:
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
line = inv.body["lines"][0]
|
||||
chk.check("line keeps the ENTERED qty (prints as 2 BOX)", float(line["qty"]), float(SELL_BOXES))
|
||||
chk.check("line keeps the ENTERED uom", line["uomId"], box_uom)
|
||||
|
||||
check = c.get(f"/sales-invoices/{inv.body['salesInvoiceId']}/posting-check")
|
||||
chk.status("posting check", check, 200)
|
||||
if check.status == 200:
|
||||
chk.check("posting check passes with enough stock", check.body["canPost"], True)
|
||||
|
||||
chk.section("3. Posting consumes the CONVERTED quantity")
|
||||
posted = c.post(f"/sales-invoices/{inv.body['salesInvoiceId']}/post")
|
||||
chk.status("post the invoice", posted, 200)
|
||||
if posted.status != 200:
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
after = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
chk.check(f"on-hand fell by {expected_base} base units, not {SELL_BOXES}", after, before - expected_base)
|
||||
|
||||
rows = c.get(f"/stock/ledger?sourceDocType=SINV&sourceDocId={inv.body['salesInvoiceId']}&pageSize=50").body["items"]
|
||||
chk.check("one ledger row for the invoice", len(rows), 1)
|
||||
if rows:
|
||||
chk.check("ledger qtyBase is the CONVERTED quantity", float(rows[0]["qtyBase"]), float(expected_base))
|
||||
chk.check("ledger row is labelled with the base UOM", rows[0]["baseUomId"], base_uom)
|
||||
|
||||
# --- the same on a slip ----------------------------------------------
|
||||
chk.section("4. A sales slip behaves identically (shared PostAsync)")
|
||||
before_slip = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
slip = c.post("/sales-slips", {
|
||||
"customerId": customer_id,
|
||||
"warehouseId": wh,
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": SELL_BOXES, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
chk.status("create the slip", slip, 201)
|
||||
if slip.status == 201:
|
||||
chk.status("post the slip", c.post(f"/sales-slips/{slip.body['salesSlipId']}/post"), 200)
|
||||
chk.check("slip also consumed the converted quantity",
|
||||
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before_slip - expected_base)
|
||||
|
||||
# --- snapshot immutability -------------------------------------------
|
||||
chk.section("5. A factor edited after save does not change what posts")
|
||||
draft = c.post("/sales-invoices", {
|
||||
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": box_uom, "warehouseId": wh,
|
||||
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
if draft.status == 201:
|
||||
# Double the factor *after* the draft is saved.
|
||||
c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR * 2}]})
|
||||
before_snap = float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"])
|
||||
chk.status("post the pre-existing draft", c.post(f"/sales-invoices/{draft.body['salesInvoiceId']}/post"), 200)
|
||||
chk.check(f"posted the snapshotted {FACTOR}, not the edited {FACTOR * 2}",
|
||||
float(c.get(f"/stock/on-hand?itemId={item['itemId']}&warehouseId={wh}").body["onHand"]),
|
||||
before_snap - FACTOR)
|
||||
# Restore for re-runnability.
|
||||
c.put(f"/items/{item['itemId']}/uom-conversions",
|
||||
{"conversions": [{"fromUom": box_uom, "toUom": base_uom, "factor": FACTOR}]})
|
||||
else:
|
||||
chk.check("could create the snapshot-test draft", draft.status, 201)
|
||||
|
||||
# --- unusable UOM refused at entry ------------------------------------
|
||||
chk.section("6. A UOM with no conversion is refused at line creation")
|
||||
third = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"]
|
||||
if u["uomId"] not in (base_uom, box_uom)), None)
|
||||
if third is None:
|
||||
chk.check("skipped: need a third UOM", True, True)
|
||||
else:
|
||||
bad = c.post("/sales-invoices", {
|
||||
"customerId": customer_id, "warehouseId": wh, "invoiceType": "B2C",
|
||||
"lines": [{
|
||||
"itemId": item["itemId"], "uomId": third, "warehouseId": wh,
|
||||
"qty": 1, "freeQty": 0, "unitPrice": 100, "allowManualPriceOverride": True,
|
||||
"discountMode": "Percentage", "discountPct": 0, "discountAmount": 0,
|
||||
"discountValue": 0, "taxPct": 0, "isFreeIssue": False,
|
||||
}],
|
||||
})
|
||||
# The point is that this fails at CREATE (while the user is editing), not at post.
|
||||
chk.status("invoice line in an unconvertible UOM", bad, 422)
|
||||
|
||||
return chk.finish("UOM-SALES")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user