15ddac178c
- 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.
261 lines
9.6 KiB
Python
261 lines
9.6 KiB
Python
"""Shared harness for the manufacturing smoke tests (docs/30-BACKEND-PHASE2.md).
|
|
|
|
The repo has no test project; verification is a live smoke test against local Postgres
|
|
with a real AuthHex session, with the assertion count recorded in Backend/PROGRESS.md.
|
|
These scripts make that repeatable instead of ad-hoc curl.
|
|
|
|
Auth note: ERPCore validates AuthHex's RS256 tokens offline against a statically
|
|
configured public key, so we log in to AuthHex *directly* and send the access token as a
|
|
Bearer header. That deliberately bypasses ERPCore's own /auth/login proxy, which would
|
|
otherwise need AuthHex:BaseUrl to match the port AuthHex actually listens on.
|
|
|
|
Usage:
|
|
python m2_templates.py [--api URL] [--auth URL] [--user EMAIL] [--password PW]
|
|
|
|
Environment variables (ERP_SMOKE_API, ERP_SMOKE_AUTH, ERP_SMOKE_USER,
|
|
ERP_SMOKE_PASSWORD) override the defaults; command-line flags override those.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# Server messages and box-drawing output contain non-cp1252 characters, and the default
|
|
# Windows console codepage would raise UnicodeEncodeError mid-report — losing exactly the
|
|
# diagnostic text a failing assertion needs to show.
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
DEFAULTS = {
|
|
"api": "http://localhost:5224/api/v1",
|
|
"auth": "http://localhost:5602",
|
|
"user": "admin@gmail.com",
|
|
"password": "Naveen@99",
|
|
}
|
|
|
|
|
|
def parse_args(description: str) -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description=description)
|
|
for key, default in DEFAULTS.items():
|
|
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
|
|
return p.parse_args()
|
|
|
|
|
|
class Response:
|
|
__slots__ = ("status", "body", "headers")
|
|
|
|
def __init__(self, status: int, body, headers: dict):
|
|
self.status = status
|
|
self.body = body
|
|
self.headers = headers
|
|
|
|
@property
|
|
def code(self):
|
|
"""The RFC 7807 domain error code, when the body carries one (docs/11 §1.8)."""
|
|
return self.body.get("code") if isinstance(self.body, dict) else None
|
|
|
|
@property
|
|
def etag(self):
|
|
return self.headers.get("ETag")
|
|
|
|
def __repr__(self):
|
|
return f"<{self.status} code={self.code}>"
|
|
|
|
|
|
class Client:
|
|
def __init__(self, api: str, token: str):
|
|
self.api = api.rstrip("/")
|
|
self.token = token
|
|
|
|
def request(self, method: str, path: str, body=None, if_match: str | None = None,
|
|
idempotency_key: str | None = None) -> Response:
|
|
url = f"{self.api}{path}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(url, data=data, method=method)
|
|
req.add_header("Accept", "application/json")
|
|
req.add_header("Authorization", f"Bearer {self.token}")
|
|
if data is not None:
|
|
req.add_header("Content-Type", "application/json")
|
|
if if_match:
|
|
req.add_header("If-Match", if_match)
|
|
if idempotency_key:
|
|
req.add_header("Idempotency-Key", idempotency_key)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req) as r:
|
|
raw = r.read()
|
|
return Response(r.status, _decode(raw), dict(r.headers))
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read()
|
|
return Response(e.code, _decode(raw), dict(e.headers))
|
|
|
|
def get(self, path):
|
|
return self.request("GET", path)
|
|
|
|
def post(self, path, body=None, **kw):
|
|
return self.request("POST", path, body, **kw)
|
|
|
|
def put(self, path, body=None, **kw):
|
|
return self.request("PUT", path, body, **kw)
|
|
|
|
def patch(self, path, body=None, **kw):
|
|
return self.request("PATCH", path, body, **kw)
|
|
|
|
|
|
def _decode(raw: bytes):
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return raw.decode(errors="replace")
|
|
|
|
|
|
def login(auth_url: str, identifier: str, password: str) -> str:
|
|
"""Obtain an AuthHex access token via its `{functionName, payload}` envelope."""
|
|
payload = {
|
|
"functionName": "loginUser",
|
|
"payload": {"identifier": identifier, "password": password, "deviceName": "erp-smoke"},
|
|
"reference": "",
|
|
}
|
|
req = urllib.request.Request(
|
|
f"{auth_url.rstrip('/')}/api/user",
|
|
data=json.dumps(payload).encode(),
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req) as r:
|
|
envelope = json.loads(r.read())
|
|
except urllib.error.URLError as e:
|
|
sys.exit(f"FATAL: cannot reach AuthHex at {auth_url} ({e}). Start ERP_Auth_Service first.")
|
|
|
|
if not envelope.get("success") or not envelope.get("data"):
|
|
sys.exit(f"FATAL: AuthHex login failed: {envelope.get('message')!r}")
|
|
return envelope["data"]["accessToken"]
|
|
|
|
|
|
class Checker:
|
|
"""Counts assertions so the pass total can be recorded in PROGRESS.md."""
|
|
|
|
def __init__(self):
|
|
self.passed = 0
|
|
self.failed = 0
|
|
|
|
def check(self, label: str, actual, expected) -> bool:
|
|
ok = actual == expected
|
|
if ok:
|
|
self.passed += 1
|
|
print(f" PASS {label}")
|
|
else:
|
|
self.failed += 1
|
|
print(f" FAIL {label}\n expected: {expected!r}\n actual: {actual!r}")
|
|
return ok
|
|
|
|
def status(self, label: str, response: Response, expected_status: int, expected_code: str | None = None):
|
|
ok = self.check(f"{label} -> {expected_status}", response.status, expected_status)
|
|
if expected_code is not None:
|
|
ok = self.check(f"{label} -> code {expected_code}", response.code, expected_code) and ok
|
|
if not ok and response.status >= 400:
|
|
detail = response.body.get("detail") if isinstance(response.body, dict) else response.body
|
|
print(f" server said: {detail}")
|
|
return ok
|
|
|
|
def section(self, title: str):
|
|
print(f"\n--- {title} ---")
|
|
|
|
def finish(self, name: str) -> int:
|
|
total = self.passed + self.failed
|
|
print(f"\n{'=' * 60}\n{name}: {self.passed}/{total} assertions passed"
|
|
+ (f" ({self.failed} FAILED)" if self.failed else " — ALL GREEN")
|
|
+ f"\n{'=' * 60}")
|
|
return 1 if self.failed else 0
|
|
|
|
|
|
def bootstrap(description: str):
|
|
"""Standard entry point: parse args, log in, return (client, checker, args)."""
|
|
args = parse_args(description)
|
|
token = login(args.auth, args.user, args.password)
|
|
return Client(args.api, token), Checker(), args
|
|
|
|
|
|
# --- stock fixtures ----------------------------------------------------------
|
|
#
|
|
# Seeding matters more than it looks. A positive stock ADJUSTMENT is the obvious way to
|
|
# create on-hand, but StockMutator's inbound path costs it at *last cost*, which is 0.00
|
|
# when the item has no prior layers. Stock seeded that way makes every cost-pool assertion
|
|
# pass trivially against zeros and proves nothing. A direct (no-PO) GRN lets us state the
|
|
# unit cost explicitly, so consumption produces a real, checkable value.
|
|
|
|
|
|
def drain_stock(c, warehouse_id: int) -> list:
|
|
"""
|
|
Zero out every item's on-hand in a warehouse via one negative adjustment.
|
|
|
|
Needed because these scripts are re-runnable and FIFO is oldest-first: stock left behind
|
|
by a previous execution is consumed *before* anything seeded now. If an earlier run left
|
|
zero-cost layers (as an adjustment-based seed does), a later run's cost assertions would
|
|
silently read 0.00 and pass against nothing. Draining first makes each execution start
|
|
from a known-empty warehouse.
|
|
"""
|
|
rows = c.get(f"/stock/on-hand/list?warehouseId={warehouse_id}&pageSize=200").body["items"]
|
|
lines = [{"itemId": r["itemId"], "qtyDelta": -float(r["onHand"])}
|
|
for r in rows if float(r["onHand"]) > 0]
|
|
if not lines:
|
|
return []
|
|
|
|
reasons = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"]
|
|
if not reasons:
|
|
sys.exit("FATAL: no Adjustment reason codes seeded.")
|
|
|
|
res = c.post("/stock-adjustments", {
|
|
"warehouseId": warehouse_id,
|
|
"reasonCodeId": reasons[0]["reasonCodeId"],
|
|
"lines": lines,
|
|
})
|
|
if res.status != 201:
|
|
sys.exit(f"FATAL: could not drain the smoke warehouse: {res.status} {res.body}")
|
|
return lines
|
|
|
|
|
|
def ensure_vendor(c) -> int:
|
|
existing = c.get("/vendors?pageSize=1").body["items"]
|
|
if existing:
|
|
return existing[0]["vendorId"]
|
|
created = c.post("/vendors", {"code": "SMOKE-V", "name": "Smoke vendor"})
|
|
if created.status != 201:
|
|
sys.exit(f"FATAL: could not create a vendor: {created.status} {created.body}")
|
|
return created.body["vendorId"]
|
|
|
|
|
|
def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) -> None:
|
|
"""
|
|
Create on-hand at explicit unit costs via a direct GRN + confirm.
|
|
|
|
`lines` is an iterable of (item_id, qty, unit_cost). Quantities are counts of the item's
|
|
base UOM — document lines carry no unit of their own.
|
|
"""
|
|
vendor_id = vendor_id or ensure_vendor(c)
|
|
grn = c.post("/grns", {
|
|
"vendorId": vendor_id,
|
|
"warehouseId": warehouse_id,
|
|
"lines": [
|
|
{"itemId": i, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
|
|
for (i, q, cost) in lines
|
|
],
|
|
})
|
|
if grn.status != 201:
|
|
sys.exit(f"FATAL: could not create the seeding GRN: {grn.status} {grn.body}")
|
|
|
|
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
|
|
if confirmed.status != 200:
|
|
sys.exit(f"FATAL: could not confirm the seeding GRN: {confirmed.status} {confirmed.body}")
|