feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:14 +03:30
parent c8a9b66395
commit 170aed66ea
67 changed files with 9774 additions and 0 deletions
+108
View File
@@ -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()
+131
View File
@@ -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)
+245
View File
@@ -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)
+308
View File
@@ -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)