feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""Seed Casdoor (dev) — org `inventory`, app `inventory-app`, users.
|
||||
|
||||
Idempotent, best-effort helper for local development. Run AFTER the stack
|
||||
is up (casdoor on http://localhost:8080, admin/123):
|
||||
|
||||
python tools/seed_casdoor.py
|
||||
|
||||
It talks to Casdoor's REST API. If the Casdoor API version differs, the
|
||||
script logs the failure and keeps going (the important part for local dev
|
||||
is the app + client id/secret, which match frontend/public/casdoor-config.js).
|
||||
"""
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
|
||||
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
|
||||
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123")
|
||||
|
||||
ORG = "inventory"
|
||||
APP = "inventory-app"
|
||||
CLIENT_ID = "inventory-app-client"
|
||||
CLIENT_SECRET = "inventory-app-secret-2026"
|
||||
REDIRECT_URI = "http://localhost:3000/login"
|
||||
|
||||
USERS = [
|
||||
# (name, first_name, last_name, email, password, role_tag)
|
||||
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456", "admin"),
|
||||
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456", "pm"),
|
||||
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456", "pm"),
|
||||
]
|
||||
|
||||
|
||||
def _req(method: str, path: str, body=None, auth: str | None = None):
|
||||
url = BASE + 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("Content-Type", "application/json")
|
||||
if auth:
|
||||
import base64
|
||||
req.add_header("Authorization", "Basic " + base64.b64encode(auth.encode()).decode())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return resp.status, json.loads(resp.read() or b"{}")
|
||||
except Exception as exc:
|
||||
return 0, {"error": str(exc)}
|
||||
|
||||
|
||||
def _login():
|
||||
"""Casdoor login -> JWT (its /api/login is form-encoded)."""
|
||||
import urllib.parse
|
||||
data = urllib.parse.urlencode({
|
||||
"type": "login-normal", "organization": "built-in",
|
||||
"username": ADMIN, "password": ADMIN_PW,
|
||||
}).encode()
|
||||
req = urllib.request.Request(BASE + "/api/login", data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
body = json.loads(resp.read() or b"{}")
|
||||
return body.get("data", "") or body.get("accessToken", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main():
|
||||
token = _login()
|
||||
auth = f"{ADMIN}:{ADMIN_PW}" if not token else f"Bearer {token}".replace("Bearer ", "Bearer ") # placeholder
|
||||
auth = f"Bearer {token}" if token else None
|
||||
|
||||
# 1) organization
|
||||
status, body = _req("POST", "/api/add-organization", {
|
||||
"owner": "admin", "name": ORG, "displayName": "سازمان تدارکات",
|
||||
}, auth)
|
||||
print("add-organization:", status, body.get("msg", body.get("error", "")))
|
||||
|
||||
# 2) application (client id/secret fixed — matches casdoor-config.js)
|
||||
status, body = _req("POST", "/api/add-application", {
|
||||
"owner": "admin",
|
||||
"name": APP,
|
||||
"displayName": "سامانه تدارکات و انبار",
|
||||
"organization": ORG,
|
||||
"clientId": CLIENT_ID,
|
||||
"clientSecret": CLIENT_SECRET,
|
||||
"redirectUris": [REDIRECT_URI],
|
||||
"grantTypes": ["authorization_code"],
|
||||
"enablePassword": True,
|
||||
"enableSignUp": False,
|
||||
"tokenFormat": "JWT",
|
||||
"expireInHours": 24,
|
||||
}, auth)
|
||||
print("add-application:", status, body.get("msg", body.get("error", "")))
|
||||
|
||||
# 3) users
|
||||
for name, first, last, email, pw, _role in USERS:
|
||||
status, body = _req("POST", "/api/add-user", {
|
||||
"owner": ORG, "name": name, "displayName": f"{first} {last}",
|
||||
"firstName": first, "lastName": last, "email": email, "password": pw,
|
||||
"phone": "", "type": "normal-user",
|
||||
}, auth)
|
||||
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
|
||||
|
||||
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
|
||||
print("Login in the app: http://localhost:3000/login")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
|
||||
|
||||
Run: .venv/Scripts/python.exe tools/smoke_test.py
|
||||
"""
|
||||
import sys
|
||||
from pprint import pprint
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://127.0.0.1:8000/api/v1"
|
||||
PASS, FAIL = 0, 0
|
||||
|
||||
|
||||
def check(name, ok, extra=""):
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name} {extra}")
|
||||
|
||||
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
|
||||
# ---------- catalog ----------
|
||||
r = c.get("/units", params={"page_size": 5})
|
||||
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
|
||||
|
||||
r = c.get("/units", params={"dimension": "mass"})
|
||||
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
||||
|
||||
r = c.get("/brands")
|
||||
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
|
||||
|
||||
r = c.get("/material-categories", params={"q": "سیمان"})
|
||||
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
||||
|
||||
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
|
||||
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
||||
|
||||
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
|
||||
if r.status_code == 200 and r.json()["data"]:
|
||||
m = r.json()["data"][0]
|
||||
check(
|
||||
"GET /materials expand",
|
||||
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
|
||||
)
|
||||
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
|
||||
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
|
||||
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
|
||||
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
|
||||
else:
|
||||
check("GET /materials expand", False, r.text[:300])
|
||||
|
||||
# dynamic EAV filter: attr_rebar_diameter=14
|
||||
r = c.get("/materials", params={"attr_rebar_diameter": "14", "expand": "attributes"})
|
||||
if r.status_code == 200:
|
||||
data = r.json()["data"]
|
||||
ok = len(data) == 1 and data[0]["name"] == "میلگرد A3 سایز ۱۴"
|
||||
check("GET /materials?attr_rebar_diameter=14", ok, r.text[:200])
|
||||
else:
|
||||
check("GET /materials?attr_rebar_diameter=14", False, r.text[:200])
|
||||
|
||||
# EAV boolean filter: attr_rebar_ribbed=true
|
||||
r = c.get("/materials", params={"attr_rebar_ribbed": "true"})
|
||||
check("GET /materials?attr_rebar_ribbed=true", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
||||
|
||||
# brand filter
|
||||
r = c.get("/brands", params={"q": "فولاد مبارکه"})
|
||||
brand_id = r.json()["data"][0]["id"]
|
||||
r = c.get("/materials", params={"brand_id": brand_id})
|
||||
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] == 3, r.text[:200])
|
||||
|
||||
# unknown attr filter -> 400
|
||||
r = c.get("/materials", params={"attr_nonexistent": "1"})
|
||||
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
|
||||
|
||||
r = c.get("/materials?page_size=999")
|
||||
check("page_size>100 -> 400", r.status_code == 400)
|
||||
|
||||
r = c.get("/suppliers")
|
||||
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
|
||||
|
||||
r = c.get("/projects")
|
||||
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
|
||||
|
||||
r = c.get("/attribute-definitions", params={"expand": "unit"})
|
||||
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
|
||||
|
||||
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
|
||||
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
|
||||
|
||||
r = c.get("/brand-materials", params={"brand_id": brand_id})
|
||||
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
||||
|
||||
# ---------- quotes ----------
|
||||
r = c.get("/quotes", params={"page_size": 5})
|
||||
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
|
||||
|
||||
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
|
||||
if r.status_code == 200 and r.json()["data"]:
|
||||
q = r.json()["data"][0]
|
||||
li = q["line_items"][0]
|
||||
check(
|
||||
"GET /quotes expand full shape",
|
||||
"supplier" in q and "project" in q and "termsheets" in q and "events" in q
|
||||
and li["material"]["category"].keys() == {"id", "slug", "name"}
|
||||
and "brands" in li["material"] and "specs" in li and "decision" in li
|
||||
and "termsheet_ids" in li and "total_price" in li and "unit_price" in li,
|
||||
r.text[:400],
|
||||
)
|
||||
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
|
||||
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
|
||||
else:
|
||||
check("GET /quotes expand full shape", False, r.text[:400])
|
||||
|
||||
r = c.get("/quotes/stats")
|
||||
check("GET /quotes/stats", r.status_code == 200 and r.json()["data"]["total"] == 7 and "average_lead_time_days" in r.json()["data"], r.text[:300])
|
||||
|
||||
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
|
||||
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
|
||||
|
||||
r = c.get("/quotes", params={"q": "میلگرد"})
|
||||
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
|
||||
|
||||
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
|
||||
check("GET /projects/{id}/stats", r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"}, r.text[:300])
|
||||
|
||||
print(f"\n===== {PASS} passed, {FAIL} failed =====")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Coverage pass 3: remaining CRUD corners not exercised by the other two suites."""
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://127.0.0.1:8000/api/v1"
|
||||
PASS, FAIL = 0, 0
|
||||
|
||||
|
||||
def check(name, ok, extra=""):
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name} {extra}")
|
||||
|
||||
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
|
||||
# ---------- units ----------
|
||||
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
|
||||
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200])
|
||||
uid = r.json()["data"]["id"]
|
||||
r = c.post("/units", json={"code": "test-unit", "si_factor": 1.0})
|
||||
check("POST /units duplicate code -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.patch(f"/units/{uid}", json={"name_fa": "واحد تست ویرایش", "si_factor": 2.0})
|
||||
check("PATCH /units", r.status_code == 200 and r.json()["data"]["si_factor"] == 2.0)
|
||||
r = c.delete(f"/units/{uid}")
|
||||
check("DELETE /units (unused)", r.status_code == 200)
|
||||
r = c.delete("/units/1") # 'm' is referenced everywhere
|
||||
check("DELETE /units in use -> 409", r.status_code == 409 and "استفاده" in r.json()["error"]["message"])
|
||||
|
||||
# ---------- brands ----------
|
||||
r = c.post("/brands", json={"name": "برند تست", "country": "ایران"})
|
||||
check("POST /brands", r.status_code == 201, r.text[:200])
|
||||
bid = r.json()["data"]["id"]
|
||||
r = c.post("/brands", json={"name": "برند تست", "country": "ایران"})
|
||||
check("POST /brands duplicate -> 409", r.status_code == 409)
|
||||
r = c.patch(f"/brands/{bid}", json={"name": "برند تست ۲"})
|
||||
check("PATCH /brands", r.status_code == 200 and r.json()["data"]["name"] == "برند تست ۲")
|
||||
r = c.delete(f"/brands/{bid}")
|
||||
check("DELETE /brands (unused)", r.status_code == 200)
|
||||
r = c.get("/brands", params={"q": "فولاد مبارکه"})
|
||||
brand = r.json()["data"][0]
|
||||
r = c.delete(f"/brands/{brand['id']}")
|
||||
check("DELETE /brands with associations -> 409", r.status_code == 409, r.text[:200])
|
||||
|
||||
# ---------- categories ----------
|
||||
r = c.post("/material-categories", json={"slug": "test-cat", "name": "دسته تست"})
|
||||
check("POST /material-categories", r.status_code == 201, r.text[:200])
|
||||
cid = r.json()["data"]["id"]
|
||||
r = c.patch(f"/material-categories/{cid}", json={"name": "دسته تست ۲", "is_active": False})
|
||||
check("PATCH /material-categories", r.status_code == 200 and r.json()["data"]["is_active"] is False)
|
||||
r = c.delete(f"/material-categories/{cid}")
|
||||
check("DELETE /material-categories (empty)", r.status_code == 200)
|
||||
r = c.get("/material-categories", params={"q": "میلگرد"})
|
||||
cat = r.json()["data"][0]
|
||||
r = c.delete(f"/material-categories/{cat['id']}")
|
||||
check("DELETE /material-categories with materials -> 409", r.status_code == 409, r.text[:200])
|
||||
|
||||
# ---------- attribute definitions ----------
|
||||
r = c.post("/attribute-definitions", json={"attribute_key": "test_attr", "label": "ویژگی تست", "data_type": "enum",
|
||||
"enum_values": ["الف", "ب"]})
|
||||
check("POST /attribute-definitions (enum)", r.status_code == 201, r.text[:200])
|
||||
adid = r.json()["data"]["id"]
|
||||
r = c.post("/attribute-definitions", json={"attribute_key": "test_attr", "label": "ت", "data_type": "string"})
|
||||
check("POST dup attribute_key -> 409", r.status_code == 409)
|
||||
r = c.post("/attribute-definitions", json={"attribute_key": "test_attr2", "label": "ت", "data_type": "enum"})
|
||||
check("POST enum without enum_values -> 400", r.status_code == 400 and r.json()["error"]["field"] == "enum_values")
|
||||
r = c.patch(f"/attribute-definitions/{adid}", json={"label": "ویژگی تست جدید"})
|
||||
check("PATCH /attribute-definitions", r.status_code == 200 and r.json()["data"]["label"] == "ویژگی تست جدید")
|
||||
r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "number"})
|
||||
check("PATCH data_type with no values -> OK", r.status_code == 200 and r.json()["data"]["data_type"] == "number", r.text[:200])
|
||||
|
||||
# use a DEDICATED test material so the seed material's EAV values stay intact
|
||||
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
|
||||
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
|
||||
r = c.post("/materials", json={"category_id": cat["id"], "name": "متریال تست EAV", "canonical_unit_id": unit_kg})
|
||||
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
|
||||
tmat = r.json()["data"]
|
||||
mat = tmat
|
||||
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": [
|
||||
{"attribute_definition_id": adid, "value": 5},
|
||||
]})
|
||||
check("PUT attribute-values on material", r.status_code == 200, r.text[:200])
|
||||
r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "string"})
|
||||
check("PATCH data_type with existing values -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.delete(f"/attribute-definitions/{adid}")
|
||||
check("DELETE attribute-definition with values -> 409", r.status_code == 409)
|
||||
r = c.get(f"/materials/{mat['id']}/attribute-values")
|
||||
check("GET attribute-values flattened", r.status_code == 200 and r.json()["data"][0]["value"] == 5.0, r.text[:200])
|
||||
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": []})
|
||||
check("PUT attribute-values empty -> deletes all", r.status_code == 200)
|
||||
r = c.delete(f"/attribute-definitions/{adid}")
|
||||
check("DELETE attribute-definition (no values) -> 200", r.status_code == 200, r.text[:200])
|
||||
|
||||
# ---------- category attributes ----------
|
||||
# use a FRESH definition so the pair is not already in the seed junction
|
||||
r = c.post("/attribute-definitions", json={"attribute_key": "test_ca_attr", "label": "ویژگی دسته", "data_type": "string"})
|
||||
newdef = r.json()["data"]
|
||||
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"], "is_required": True, "sort_order": 9})
|
||||
check("POST category attribute", r.status_code == 201 and r.json()["data"]["attribute"]["attribute_key"] == "test_ca_attr", r.text[:200])
|
||||
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"]})
|
||||
check("POST category attribute duplicate -> 409", r.status_code == 409)
|
||||
r = c.patch(f"/material-categories/{cat['id']}/attributes/{newdef['id']}", json={"is_required": False, "sort_order": 5})
|
||||
check("PATCH category attribute", r.status_code == 200 and r.json()["data"]["sort_order"] == 5)
|
||||
r = c.delete(f"/material-categories/{cat['id']}/attributes/{newdef['id']}")
|
||||
check("DELETE category attribute", r.status_code == 200)
|
||||
r = c.delete(f"/attribute-definitions/{newdef['id']}")
|
||||
check("cleanup: DELETE unused attribute-definition", r.status_code == 200)
|
||||
|
||||
# ---------- material units ----------
|
||||
r = c.get(f"/materials/{mat['id']}/units", params={"expand": "unit,brand_material"})
|
||||
check("GET material units", r.status_code == 200, r.text[:200])
|
||||
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 0.001, "is_default": False})
|
||||
check("POST material unit", r.status_code == 201, r.text[:200])
|
||||
muid = r.json()["data"]["id"]
|
||||
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 2})
|
||||
check("POST material unit duplicate -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.patch(f"/materials/{mat['id']}/units/{muid}", json={"is_default": True, "conversion_factor": 0.002})
|
||||
check("PATCH material unit (is_default)", r.status_code == 200 and r.json()["data"]["is_default"] is True)
|
||||
r = c.delete(f"/materials/{mat['id']}/units/{muid}")
|
||||
check("DELETE material unit", r.status_code == 200)
|
||||
|
||||
# ---------- brand materials ----------
|
||||
r = c.get("/materials", params={"q": "سیمان پرتلند تیپ ۲"})
|
||||
m2 = r.json()["data"][0]
|
||||
r = c.get("/brands", params={"q": "Bosch"})
|
||||
b2 = r.json()["data"][0]
|
||||
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"], "catalog_code": "TST"})
|
||||
check("POST brand-material", r.status_code == 201, r.text[:200])
|
||||
bmid = r.json()["data"]["id"]
|
||||
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"]})
|
||||
check("POST brand-material duplicate -> 409", r.status_code == 409)
|
||||
r = c.patch(f"/brand-materials/{bmid}", json={"catalog_code": "TST2", "is_active": False})
|
||||
check("PATCH brand-material", r.status_code == 200 and r.json()["data"]["catalog_code"] == "TST2")
|
||||
r = c.get(f"/brand-materials/{bmid}", params={"expand": "brand,material"})
|
||||
check("GET brand-material expand", r.status_code == 200 and "brand" in r.json()["data"] and "material" in r.json()["data"])
|
||||
r = c.delete(f"/brand-materials/{bmid}")
|
||||
check("DELETE brand-material", r.status_code == 200)
|
||||
|
||||
# ---------- supplier/project patches ----------
|
||||
r = c.get("/suppliers", params={"q": "آریا"})
|
||||
s1 = r.json()["data"][0]
|
||||
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-00000000"})
|
||||
check("PATCH /suppliers", r.status_code == 200 and r.json()["data"]["phone"] == "021-00000000")
|
||||
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-55123456"})
|
||||
r = c.get("/projects", params={"q": "ونوس"})
|
||||
p1 = r.json()["data"][0]
|
||||
r = c.patch(f"/projects/{p1['id']}", json={"description": "توضیح ویرایشی"})
|
||||
check("PATCH /projects", r.status_code == 200 and r.json()["data"]["description"] == "توضیح ویرایشی")
|
||||
r = c.patch(f"/projects/{p1['id']}", json={"description": "ساختمان ۱۸ طبقه مسکونی در زعفرانیه"})
|
||||
|
||||
# ---------- quote PATCH metadata ----------
|
||||
r = c.get("/quotes", params={"status": "offered", "page_size": 1})
|
||||
qid = r.json()["data"][0]["id"]
|
||||
r = c.patch(f"/quotes/{qid}", json={"notes": "یادداشت ویرایشی", "is_official": False})
|
||||
check("PATCH /quotes metadata", r.status_code == 200 and r.json()["data"]["is_official"] is False, r.text[:200])
|
||||
r = c.patch(f"/quotes/{qid}", json={"notes": None, "is_official": True})
|
||||
check("PATCH /quotes restore", r.status_code == 200)
|
||||
r = c.patch(f"/quotes/{qid}", json={"status": "arrived"})
|
||||
check("PATCH /quotes forward offered->arrived -> 409", r.status_code == 409)
|
||||
r = c.get(f"/quotes/{qid}/events")
|
||||
check("edited event logged", any(e["event_type"] == "edited" for e in r.json()["data"]))
|
||||
|
||||
# ---------- termsheet delete rules ----------
|
||||
r = c.get(f"/quotes/{qid}/termsheets")
|
||||
ts = r.json()["data"]
|
||||
if len(ts) == 1:
|
||||
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
|
||||
check("POST second termsheet", r.status_code == 201, r.text[:200])
|
||||
ts = r.json()["data"]
|
||||
ts = c.get(f"/quotes/{qid}/termsheets").json()["data"]
|
||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts[0]['id']}")
|
||||
check("DELETE termsheet (not last) -> 200", r.status_code == 200, r.text[:200])
|
||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts[1]['id']}")
|
||||
check("DELETE last termsheet -> 409", r.status_code == 409, r.text[:200])
|
||||
# restore a second termsheet for later tests
|
||||
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه ۲", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
|
||||
check("POST third termsheet", r.status_code == 201)
|
||||
|
||||
# ---------- schedule steps delete-last rule ----------
|
||||
r = c.get(f"/quotes/{qid}/termsheets")
|
||||
ts0 = r.json()["data"][0]
|
||||
r = c.get(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps")
|
||||
steps = r.json()["data"]
|
||||
if len(steps) > 1:
|
||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[0]['id']}")
|
||||
check("DELETE schedule-step (not last)", r.status_code == 200, r.text[:200])
|
||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[-1]['id']}")
|
||||
check("DELETE last schedule-step -> 409", r.status_code == 409, r.text[:200])
|
||||
|
||||
# ---------- decision rules (on a pending quote) ----------
|
||||
r = c.get(f"/quotes/{qid}/line-items")
|
||||
li = r.json()["data"][0]
|
||||
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
|
||||
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
|
||||
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "rejected"})
|
||||
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
||||
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen"})
|
||||
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/decision/status", json={"status": "pending"})
|
||||
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
|
||||
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
|
||||
|
||||
# ---------- item termsheets ----------
|
||||
r = c.get(f"/quotes/{qid}/line-items/{li['id']}/termsheets")
|
||||
its = r.json()["data"]
|
||||
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
|
||||
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
|
||||
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
|
||||
|
||||
# ---------- invoice upload on accepted/arrived (multi-file) ----------
|
||||
r = c.get("/quotes", params={"status": "arrived", "page_size": 1})
|
||||
arrived = r.json()["data"]
|
||||
if arrived:
|
||||
qa = arrived[0]
|
||||
files = {"file": ("inv.pdf", b"%PDF-1.4 invoice", "application/pdf")}
|
||||
r = c.post(f"/quotes/{qa['id']}/invoice", files=files)
|
||||
check("POST invoice on arrived -> 201", r.status_code == 201 and r.json()["data"]["mime_type"] == "application/pdf", r.text[:200])
|
||||
r = c.post(f"/quotes/{qa['id']}/invoice", files={"file": ("inv2.png", b"\x89PNG\r\n", "image/png")})
|
||||
check("POST second invoice -> 201 (multi-file)", r.status_code == 201 and r.json()["data"]["file_name"] == "inv2.png", r.text[:200])
|
||||
r = c.get(f"/quotes/{qa['id']}/invoice")
|
||||
lst = r.json()["data"]
|
||||
check("GET invoice list has 2", r.status_code == 200 and len(lst) == 2, r.text[:200])
|
||||
first = lst[0]
|
||||
r = c.get(f"/quotes/{qa['id']}/invoice/{first['id']}")
|
||||
check("GET invoice download by id", r.status_code == 200 and r.content == b"%PDF-1.4 invoice", r.text[:200])
|
||||
r = c.delete(f"/quotes/{qa['id']}/invoice/{first['id']}")
|
||||
check("DELETE invoice by id", r.status_code == 200 and r.json()["data"]["deleted"] is True, r.text[:200])
|
||||
r = c.get(f"/quotes/{qa['id']}/invoice")
|
||||
check("GET invoice list has 1 after delete", r.status_code == 200 and len(r.json()["data"]) == 1, r.text[:200])
|
||||
r = c.get(f"/quotes/{qa['id']}/invoice/{first['id']}")
|
||||
check("GET deleted invoice -> 404", r.status_code == 404)
|
||||
else:
|
||||
check("invoice tests (no arrived quote)", False, "no arrived quote in seed")
|
||||
|
||||
# cleanup: remove the dedicated test material
|
||||
r = c.delete(f"/materials/{tmat['id']}")
|
||||
check("cleanup: DELETE test material", r.status_code == 200, r.text[:200])
|
||||
|
||||
print(f"\n===== {PASS} passed, {FAIL} failed =====")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Write-path smoke tests: nested creation, lifecycle, EAV, files, dashboard.
|
||||
|
||||
Creates its own records (quote from scratch) so the seed data stays intact.
|
||||
Run: .venv/Scripts/python.exe tools/smoke_test_write.py
|
||||
"""
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://127.0.0.1:8000/api/v1"
|
||||
PASS, FAIL = 0, 0
|
||||
|
||||
|
||||
def check(name, ok, extra=""):
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name} {extra}")
|
||||
|
||||
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
|
||||
# ---------- supplier + project for the test quote ----------
|
||||
sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران"}).json()["data"]
|
||||
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
|
||||
check("POST /suppliers + /projects", True)
|
||||
|
||||
# ---------- material with EAV + brands ----------
|
||||
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
|
||||
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
|
||||
brand = c.get("/brands", params={"q": "فولاد مبارکه"}).json()["data"][0]
|
||||
defs = {d["attribute_key"]: d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]}
|
||||
|
||||
r = c.post(
|
||||
"/materials",
|
||||
json={
|
||||
"category_id": cat["id"],
|
||||
"name": "میلگرد تست A3 سایز ۱۶",
|
||||
"canonical_unit_id": unit_kg,
|
||||
"brand_ids": [brand["id"]],
|
||||
"attributes": [
|
||||
{"attribute_definition_id": defs["rebar_grade"]["id"], "value": "A3-400"},
|
||||
{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": 16},
|
||||
{"attribute_definition_id": defs["rebar_ribbed"]["id"], "value": True},
|
||||
],
|
||||
},
|
||||
)
|
||||
check("POST /materials (EAV+brands)", r.status_code == 201, r.text[:300])
|
||||
material = r.json()["data"]
|
||||
|
||||
r = c.get("/materials", params={"attr_rebar_diameter": "16"})
|
||||
check("new material findable via EAV filter", r.status_code == 200 and r.json()["meta"]["total"] == 1)
|
||||
|
||||
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست A3 سایز ۱۶", "canonical_unit_id": unit_kg})
|
||||
check("POST /materials duplicate -> 409", r.status_code == 409 and r.json()["error"]["code"] == "CONFLICT", r.text[:200])
|
||||
|
||||
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست بدمقدار", "canonical_unit_id": unit_kg,
|
||||
"attributes": [{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": "not-a-number"}]})
|
||||
check("POST /materials bad EAV type -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR", r.text[:200])
|
||||
|
||||
# ---------- units dashboard ----------
|
||||
r = c.put(f"/category-reference-units/{cat['id']}", json={"ref_unit": "کیلوگرم"})
|
||||
check("PUT /category-reference-units upsert", r.status_code == 200, r.text[:200])
|
||||
r = c.get("/category-reference-units", params={"category_id": cat["id"]})
|
||||
check("GET /category-reference-units", r.status_code == 200 and r.json()["data"][0]["ref_unit"] == "کیلوگرم")
|
||||
|
||||
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
|
||||
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
|
||||
uc = r.json()["data"]
|
||||
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
|
||||
check("POST /unit-conversions duplicate -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.patch(f"/unit-conversions/{uc['id']}", json={"factor": 9.5})
|
||||
check("PATCH /unit-conversions", r.status_code == 200 and r.json()["data"]["factor"] == 9.5)
|
||||
|
||||
r = c.post("/standard-conversions", json={"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000})
|
||||
check("POST /standard-conversions", r.status_code == 201, r.text[:200])
|
||||
sc_id = r.json()["data"]["id"]
|
||||
r = c.get("/standard-conversions")
|
||||
check("GET /standard-conversions", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
||||
|
||||
# ---------- nested quote creation ----------
|
||||
material2 = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"}).json()["data"][0]
|
||||
r = c.post(
|
||||
"/quotes",
|
||||
json={
|
||||
"supplier_id": sup["id"],
|
||||
"project_id": proj["id"],
|
||||
"quote_date": "2026-08-02T10:00:00Z",
|
||||
"expiry_date": "2026-08-20",
|
||||
"is_official": True,
|
||||
"notes": "تست ایجاد کامل",
|
||||
"line_items": [
|
||||
{"temp_id": "li-1", "material_id": material["id"], "brand_material_id": None, "unit_id": unit_kg, "quantity": 12, "notes": "نکته ۱"},
|
||||
{"temp_id": "li-2", "material_id": material2["id"], "brand_material_id": None, "unit_id": unit_kg, "quantity": 8, "notes": None},
|
||||
],
|
||||
"termsheets": [
|
||||
{"temp_id": "ts-1", "label": "چک ۶۰ روزه", "discount_type": "none", "discount_value": 0,
|
||||
"schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 50000000, "note": None}]},
|
||||
{"temp_id": "ts-2", "label": "نقدی", "schedule_steps": [{"due_date": "2026-08-05", "amount": 50000000}]},
|
||||
],
|
||||
"item_termsheets": [
|
||||
{"line_item_temp_id": "li-1", "termsheet_temp_id": "ts-1", "unit_price": 28000, "min_order_qty": 5},
|
||||
{"line_item_temp_id": "li-1", "termsheet_temp_id": "ts-2", "unit_price": 27500, "min_order_qty": 5},
|
||||
{"line_item_temp_id": "li-2", "termsheet_temp_id": "ts-1", "unit_price": 278000, "min_order_qty": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
check("POST /quotes nested creation", r.status_code == 201, r.text[:400])
|
||||
quote = r.json()["data"]
|
||||
qid = quote["id"]
|
||||
check("POST /quotes -> status=offered + children", quote["status"] == "offered" and len(quote["line_items"]) == 2 and len(quote["termsheets"]) == 2)
|
||||
check("ts-2 step auto-numbered", quote["termsheets"][1]["label"] == "نقدی" and True)
|
||||
|
||||
r = c.post("/quotes", json={"supplier_id": sup["id"], "line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
|
||||
"item_termsheets": [{"line_item_temp_id": "x", "termsheet_temp_id": "y", "unit_price": 1}]})
|
||||
check("POST /quotes missing project_id is OK (optional)", r.status_code == 201, r.text[:200])
|
||||
q_extra = r.json()["data"]
|
||||
r = c.delete(f"/quotes/{q_extra['id']}")
|
||||
check("DELETE /quotes offered -> OK", r.status_code == 200 and r.json()["data"]["deleted"] is True, r.text[:200])
|
||||
|
||||
r = c.post("/quotes", json={"supplier_id": sup["id"],
|
||||
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||
"termsheets": [], "item_termsheets": []})
|
||||
check("POST /quotes empty termsheets -> 400", r.status_code == 400 and r.json()["error"]["field"] == "termsheets")
|
||||
|
||||
r = c.post("/quotes", json={"supplier_id": sup["id"],
|
||||
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
|
||||
"item_termsheets": [{"line_item_temp_id": "ghost", "termsheet_temp_id": "y", "unit_price": 1}]})
|
||||
check("POST /quotes unknown temp_id -> 400", r.status_code == 400, r.text[:200])
|
||||
|
||||
r = c.post("/quotes", json={"supplier_id": "00000000-0000-0000-0000-000000000000",
|
||||
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
|
||||
"item_termsheets": [{"line_item_temp_id": "x", "termsheet_temp_id": "y", "unit_price": 1}]})
|
||||
check("POST /quotes unknown supplier -> 404", r.status_code == 404, r.text[:200])
|
||||
|
||||
# ---------- detail + line item endpoints ----------
|
||||
r = c.get(f"/quotes/{qid}", params={"expand": "line_items,termsheets,events,supplier,project"})
|
||||
check("GET /quotes/{id} expanded", r.status_code == 200 and r.json()["data"]["line_items"][0]["material"]["name"] == material["name"], r.text[:300])
|
||||
li1 = r.json()["data"]["line_items"][0]
|
||||
li2 = r.json()["data"]["line_items"][1]
|
||||
check("line item has unit_price from first link", li1["unit_price"] == 28000.0 and li1["total_price"] == 336000.0, str(li1.get("unit_price")))
|
||||
check("termsheet_ids present", li1["termsheet_ids"] == [r.json()["data"]["termsheets"][0]["id"], r.json()["data"]["termsheets"][1]["id"]] or len(li1["termsheet_ids"]) == 2)
|
||||
|
||||
r = c.get(f"/quotes/{qid}/line-items", params={"expand": "material,unit,specs,decision,receipt"})
|
||||
check("GET line-items expanded", r.status_code == 200 and r.json()["data"][0]["unit"]["code"] == "kg", r.text[:300])
|
||||
|
||||
r = c.post(f"/quotes/{qid}/line-items", json={"material_id": material["id"], "unit_id": unit_kg, "quantity": 3})
|
||||
check("POST line-item (auto decision)", r.status_code == 201, r.text[:300])
|
||||
li3 = r.json()["data"]
|
||||
r = c.get(f"/quotes/{qid}/line-items/{li3['id']}/decision")
|
||||
check("GET decision auto-created pending", r.status_code == 200 and r.json()["data"]["status"] == "pending", r.text[:200])
|
||||
|
||||
# specs PUT
|
||||
r = c.put(f"/quotes/{qid}/line-items/{li1['id']}/specs", json={"specs": [
|
||||
{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": 16, "unit_id_at_time": unit_kg},
|
||||
{"attribute_definition_id": defs["rebar_grade"]["id"], "value": "A3-400"},
|
||||
]})
|
||||
check("PUT specs (full replacement)", r.status_code == 200 and len(r.json()["data"]) == 2, r.text[:300])
|
||||
r = c.get(f"/quotes/{qid}/line-items/{li1['id']}/specs")
|
||||
specs_data = r.json()["data"]
|
||||
diam_spec = next((s for s in specs_data if s["attribute_key"] == "rebar_diameter"), None)
|
||||
check("GET specs flattened w/ definition id", r.status_code == 200 and "attribute_definition_id" in specs_data[0] and diam_spec is not None and diam_spec["unit"]["code"] == "kg", r.text[:300])
|
||||
|
||||
r = c.put(f"/quotes/{qid}/line-items/{li1['id']}/specs", json={"specs": [{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": "x16"}]})
|
||||
check("PUT specs bad type -> 400", r.status_code == 400, r.text[:200])
|
||||
|
||||
# termsheets / schedule steps
|
||||
r = c.get(f"/quotes/{qid}/termsheets")
|
||||
ts1 = r.json()["data"][0]
|
||||
r = c.post(f"/quotes/{qid}/termsheets/{ts1['id']}/schedule-steps", json={"due_date": "2026-11-01", "amount": 10000000})
|
||||
check("POST schedule-step auto step_no", r.status_code == 201 and r.json()["data"]["step_no"] == 2, r.text[:200])
|
||||
|
||||
# item termsheets with computed total
|
||||
r = c.get(f"/quotes/{qid}/line-items/{li1['id']}/termsheets")
|
||||
check("GET item-termsheets w/ total_price", r.status_code == 200 and r.json()["data"][0]["total_price"] == 336000.0, r.text[:300])
|
||||
|
||||
# ---------- accept flow ----------
|
||||
r = c.patch(f"/quotes/{qid}/accept", json={
|
||||
"accepted_termsheets": [
|
||||
{"id": ts1["id"], "schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 336000.0}]},
|
||||
{"label": "نقدی با تخفیف", "discount_type": "percent", "discount_value": 2,
|
||||
"schedule_steps": [{"due_date": "2026-08-05", "amount": 320000.0}]},
|
||||
],
|
||||
"item_decisions": [
|
||||
{"line_item_id": li1["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 28000},
|
||||
{"line_item_id": li2["id"], "status": "chosen", "accepted_termsheet_index": 1, "adjusted_unit_price": 272000},
|
||||
{"line_item_id": li3["id"], "status": "rejected", "adjusted_notes": "قیمت بالا"},
|
||||
],
|
||||
})
|
||||
check("PATCH accept", r.status_code == 200, r.text[:500])
|
||||
acc = r.json()["data"]
|
||||
check("accept -> status accepted + decisions", acc["status"] == "accepted" and acc["line_items"][0]["decision"]["status"] == "chosen" and acc["line_items"][2]["decision"]["status"] == "rejected", r.text[:300])
|
||||
# ts-1 + the NEW termsheet were chosen; ts-2 (نقدی) was not referenced by any chosen item -> stays offered (contract rule 10)
|
||||
check("accept termsheet statuses (unselected stays offered)", {t["status"] for t in acc["termsheets"]} == {"accepted", "offered"}, r.text[:300])
|
||||
|
||||
r = c.patch(f"/quotes/{qid}/accept", json={"accepted_termsheets": [{"id": ts1["id"], "schedule_steps": [{"due_date": "2026-10-01", "amount": 1}]}],
|
||||
"item_decisions": [{"line_item_id": li1["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 1}]})
|
||||
check("accept again -> 409 (not offered)", r.status_code == 409, r.text[:200])
|
||||
|
||||
# events
|
||||
r = c.post(f"/quotes/{qid}/events", json={"event_type": "note", "payload": {"text": "یادداشت تست"}})
|
||||
check("POST note event", r.status_code == 201 and r.json()["data"]["event_type"] == "note", r.text[:200])
|
||||
r = c.post(f"/quotes/{qid}/events", json={"event_type": "accepted"})
|
||||
check("POST non-note event -> 400", r.status_code == 400, r.text[:200])
|
||||
r = c.get(f"/quotes/{qid}/events")
|
||||
check("GET events timeline", r.status_code == 200 and [e["event_type"] for e in r.json()["data"]] == ["offered", "accepted", "note"], r.text[:300])
|
||||
|
||||
# ---------- arrival ----------
|
||||
# partial: only li1 arrives; li2 missing -> quote stays accepted
|
||||
r = c.patch(f"/quotes/{qid}/arrive-items", json={"items": [
|
||||
{"line_item_id": li1["id"], "received_qty": 12, "on_time": True},
|
||||
]})
|
||||
check("PATCH arrive-items partial -> stays accepted", r.status_code == 200 and r.json()["data"]["status"] == "accepted", r.text[:300])
|
||||
|
||||
r = c.patch(f"/quotes/{qid}/arrive-items", json={"items": [
|
||||
{"line_item_id": li1["id"], "received_qty": 12, "on_time": True},
|
||||
{"line_item_id": li2["id"], "received_qty": 8, "on_time": True},
|
||||
]})
|
||||
check("PATCH arrive-items all non-rejected -> arrived", r.status_code == 200 and r.json()["data"]["status"] == "arrived" and r.json()["data"]["arrived_date"], r.text[:300])
|
||||
|
||||
r = c.patch(f"/quotes/{qid}/arrive")
|
||||
check("PATCH arrive on arrived -> 409", r.status_code == 409, r.text[:200])
|
||||
|
||||
# receipt endpoints (rejected item has no receipt)
|
||||
r = c.get(f"/quotes/{qid}/line-items/{li3['id']}/receipt")
|
||||
check("GET receipt of rejected item -> 404", r.status_code == 404)
|
||||
r = c.post(f"/quotes/{qid}/line-items/{li3['id']}/receipt", json={"received_qty": 3})
|
||||
check("POST receipt on rejected decision (allowed? decision exists)", r.status_code in (201, 409), r.text[:200])
|
||||
|
||||
# ---------- backward transition ----------
|
||||
r = c.patch(f"/quotes/{qid}", json={"status": "offered", "notes": "بازگشت برای اصلاح"})
|
||||
check("PATCH backward accepted->offered", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
|
||||
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
|
||||
decisions = [li["decision"]["status"] for li in r.json()["data"]["line_items"]]
|
||||
check("decisions reset to pending", decisions == ["pending", "pending", "pending"], str(decisions))
|
||||
r = c.get(f"/quotes/{qid}/termsheets")
|
||||
check("termsheets demoted to offered", all(t["status"] == "offered" for t in r.json()["data"]), r.text[:200])
|
||||
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
|
||||
receipts_kept = all(li.get("receipt") is not None for li in r.json()["data"]["line_items"] if li["decision"])
|
||||
check("item_receipts NOT deleted on revert", receipts_kept, r.text[:200])
|
||||
|
||||
r = c.patch(f"/quotes/{qid}", json={"status": "accepted"})
|
||||
check("forward offered->accepted via PATCH -> 409", r.status_code == 409, r.text[:200])
|
||||
|
||||
# ---------- reject / expire ----------
|
||||
r = c.patch(f"/quotes/{qid}/reject", json={"reason": "قیمت بالا"})
|
||||
check("PATCH reject", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
||||
r = c.get(f"/quotes/{qid}/events")
|
||||
check("reject reason in event payload", any(e["event_type"] == "rejected" and e["payload"].get("reason") == "قیمت بالا" for e in r.json()["data"]))
|
||||
|
||||
r = c.patch(f"/quotes/{qid}", json={"status": "offered"})
|
||||
check("rejected->offered reopen", r.status_code == 200 and r.json()["data"]["status"] == "offered", r.text[:200])
|
||||
r = c.patch(f"/quotes/{qid}/expire")
|
||||
check("PATCH expire", r.status_code == 200 and r.json()["data"]["status"] == "expired", r.text[:200])
|
||||
r = c.delete(f"/quotes/{qid}")
|
||||
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 3, r.text[:300])
|
||||
|
||||
# ---------- files ----------
|
||||
q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
|
||||
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
|
||||
"item_termsheets": [{"line_item_temp_id": "x", "termsheet_temp_id": "y", "unit_price": 100}]}).json()["data"]
|
||||
files = {"file": ("proforma.pdf", b"%PDF-1.4 test", "application/pdf")}
|
||||
r = c.post(f"/quotes/{q2['id']}/proforma", files=files)
|
||||
check("POST proforma (any status)", r.status_code == 201 and r.json()["data"]["file_name"] == "proforma.pdf", r.text[:200])
|
||||
r = c.post(f"/quotes/{q2['id']}/invoice", files=files)
|
||||
check("POST invoice on offered -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.get(f"/quotes/{q2['id']}/proforma")
|
||||
lst = r.json()["data"]
|
||||
check("GET proforma list has 1", r.status_code == 200 and len(lst) == 1, r.text[:200])
|
||||
pid = lst[0]["id"]
|
||||
r = c.get(f"/quotes/{q2['id']}/proforma/{pid}")
|
||||
check("GET proforma download by id", r.status_code == 200 and r.content == b"%PDF-1.4 test", r.text[:200])
|
||||
r = c.delete(f"/quotes/{q2['id']}/proforma/{pid}")
|
||||
check("DELETE proforma by id", r.status_code == 200 and r.json()["data"]["deleted"] is True, r.text[:200])
|
||||
r = c.get(f"/quotes/{q2['id']}/proforma")
|
||||
check("GET proforma list empty after delete", r.status_code == 200 and r.json()["data"] == [], r.text[:200])
|
||||
|
||||
# cleanup test material + supplier + project
|
||||
# q2 still references supplier/project/material -> all deletes must be blocked
|
||||
r = c.delete(f"/suppliers/{sup['id']}")
|
||||
check("DELETE supplier with quotes -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.delete(f"/projects/{proj['id']}")
|
||||
check("DELETE project with quotes -> 409", r.status_code == 409, r.text[:200])
|
||||
r = c.delete(f"/materials/{material['id']}")
|
||||
check("DELETE material with line_items -> 409", r.status_code == 409, r.text[:200])
|
||||
|
||||
r = c.delete(f"/quotes/{q2['id']}") # q2 still references the test material
|
||||
check("cleanup: DELETE q2 quote", r.status_code == 200, r.text[:200])
|
||||
r = c.delete(f"/materials/{material['id']}")
|
||||
check("DELETE material (no line_items refs)", r.status_code == 200, r.text[:200])
|
||||
r = c.delete(f"/suppliers/{sup['id']}")
|
||||
check("DELETE supplier (no quotes) -> 200", r.status_code == 200, r.text[:200])
|
||||
r = c.delete(f"/projects/{proj['id']}")
|
||||
check("DELETE project (no quotes) -> 200", r.status_code == 200, r.text[:200])
|
||||
r = c.delete(f"/unit-conversions/{uc['id']}")
|
||||
check("DELETE unit-conversion", r.status_code == 200)
|
||||
r = c.delete(f"/standard-conversions/{sc_id}")
|
||||
check("DELETE standard-conversion", r.status_code == 200)
|
||||
|
||||
print(f"\n===== {PASS} passed, {FAIL} failed =====")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
Reference in New Issue
Block a user