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
View File
+166
View File
@@ -0,0 +1,166 @@
"""Pydantic v2 request schemas — catalog domain."""
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
MaterialStatus = Literal["active", "discontinued", "draft"]
AttributeDataType = Literal["number", "integer", "string", "enum", "boolean", "date"]
class UnitCreate(APIModel):
code: str = Field(min_length=1, max_length=255)
name_fa: str | None = None
dimension: str | None = None
si_factor: Decimal = Field(default=Decimal("1.0"), gt=0)
is_base: bool = False
class UnitUpdate(APIModel):
code: str | None = Field(default=None, min_length=1, max_length=255)
name_fa: str | None = None
dimension: str | None = None
si_factor: Decimal | None = Field(default=None, gt=0)
is_base: bool | None = None
class BrandCreate(APIModel):
name: str = Field(min_length=1)
country: str | None = None
class BrandUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
country: str | None = None
class MaterialCategoryCreate(APIModel):
slug: str = Field(min_length=1)
name: str = Field(min_length=1)
description: str | None = None
is_active: bool = True
class MaterialCategoryUpdate(APIModel):
slug: str | None = Field(default=None, min_length=1)
name: str | None = Field(default=None, min_length=1)
description: str | None = None
is_active: bool | None = None
class AttributeDefinitionCreate(APIModel):
attribute_key: str = Field(min_length=1)
label: str = Field(min_length=1)
data_type: AttributeDataType
unit_id: int | None = None
enum_values: list | None = None
class AttributeDefinitionUpdate(APIModel):
label: str | None = Field(default=None, min_length=1)
data_type: AttributeDataType | None = None
unit_id: int | None = None
enum_values: list | None = None
class CategoryAttributeCreate(APIModel):
attribute_definition_id: UUID
is_required: bool = False
is_filterable: bool = False
conditional_rules: dict | None = None
sort_order: int = 0
class CategoryAttributeUpdate(APIModel):
is_required: bool | None = None
is_filterable: bool | None = None
conditional_rules: dict | None = None
sort_order: int | None = None
class MaterialAttributeIn(APIModel):
"""One EAV value. `value` is coerced against attribute_definitions.data_type."""
attribute_definition_id: UUID
value: int | float | str | bool | None = None
class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int
status: MaterialStatus = "active"
brand_ids: list[UUID] = Field(default_factory=list)
attributes: list[MaterialAttributeIn] = Field(default_factory=list)
class MaterialUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
topic: str | None = None
canonical_unit_id: int | None = None
status: MaterialStatus | None = None
class BrandMaterialCreate(APIModel):
brand_id: UUID
material_id: UUID
catalog_code: str | None = None
is_active: bool = True
class BrandMaterialUpdate(APIModel):
# brand_id / material_id are immutable (UNIQUE pair) — not accepted here.
catalog_code: str | None = None
is_active: bool | None = None
class MaterialUnitCreate(APIModel):
brand_material_id: UUID | None = None
unit_id: int
conversion_factor: Decimal = Field(gt=0)
is_default: bool = False
class MaterialUnitUpdate(APIModel):
unit_id: int | None = None
conversion_factor: Decimal | None = Field(default=None, gt=0)
is_default: bool | None = None
class MaterialAttributeValuesPut(APIModel):
attributes: list[MaterialAttributeIn] = Field(default_factory=list)
class CategoryReferenceUnitPut(APIModel):
ref_unit: str = Field(min_length=1)
class UnitConversionCreate(APIModel):
category_id: UUID
brand: str | None = None
from_unit: str = Field(min_length=1)
to_unit: str = Field(min_length=1)
factor: Decimal = Field(gt=0)
class UnitConversionUpdate(APIModel):
brand: str | None = None
from_unit: str | None = Field(default=None, min_length=1)
to_unit: str | None = Field(default=None, min_length=1)
factor: Decimal | None = Field(default=None, gt=0)
class StandardConversionCreate(APIModel):
from_unit: str = Field(min_length=1)
to_unit: str = Field(min_length=1)
factor: Decimal = Field(gt=0)
class StandardConversionUpdate(APIModel):
from_unit: str | None = Field(default=None, min_length=1)
to_unit: str | None = Field(default=None, min_length=1)
factor: Decimal | None = Field(default=None, gt=0)
+18
View File
@@ -0,0 +1,18 @@
"""Shared schema building blocks (pagination meta, error envelope)."""
from pydantic import BaseModel, ConfigDict
class Meta(BaseModel):
total: int
page: int
page_size: int
class ErrorBody(BaseModel):
error: dict
class APIModel(BaseModel):
"""Base for request schemas — Pydantic v2, permissive on extra fields."""
model_config = ConfigDict(str_strip_whitespace=True)
+1
View File
@@ -0,0 +1 @@
"""Pydantic schemas for file endpoints — request side is a multipart UploadFile."""
+78
View File
@@ -0,0 +1,78 @@
"""Pydantic v2 request schemas — inventory domain (fork's contract, unchanged)."""
from datetime import datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.models.inventory import INVENTORY_TRANSACTION_TYPES
from app.schemas.common import APIModel
TransactionType = Literal[
"PURCHASE",
"WITHDRAWAL",
"RETURN",
"TRANSFER_IN",
"TRANSFER_OUT",
"DAMAGE",
"WASTE",
"LOSS",
"FOUND",
"STOCK_COUNT",
]
class WarehouseCreate(APIModel):
project_id: UUID
name: str = Field(min_length=1, max_length=255)
description: str | None = None
class WarehouseUpdate(APIModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
project_id: UUID | None = None
class InventoryTransactionCreate(APIModel):
"""One inventory operation.
- For all types except STOCK_COUNT: `quantity` is the amount to add/remove.
- STOCK_COUNT uses `physical_qty` (the counted quantity); the difference
against the recorded quantity is computed and applied by the service.
- TRANSFER_OUT requires `dest_warehouse_id`; the service creates the linked
TRANSFER_IN row in the destination warehouse (transfer_pair_id).
- `specs` is a dict of technical specifications (dynamic multi-spec entries).
- `transaction_date` defaults to server now() when omitted.
"""
transaction_type: TransactionType
warehouse_id: UUID
dest_warehouse_id: UUID | None = None
material_id: UUID
brand_id: UUID | None = None
quantity: Decimal | None = Field(default=None, gt=0)
physical_qty: Decimal | None = Field(default=None, ge=0)
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
def validate_type_rules(self):
t = self.transaction_type
if t == "STOCK_COUNT":
if self.physical_qty is None:
raise ValueError("برای شمارش موجودی، تعداد شمارش‌شده (physical_qty) الزامی است")
else:
if self.quantity is None:
raise ValueError("تعداد (quantity) الزامی است")
if t == "TRANSFER_OUT":
if self.dest_warehouse_id is None:
raise ValueError("برای انتقال خروجی، انبار مقصد (dest_warehouse_id) الزامی است")
if self.dest_warehouse_id == self.warehouse_id:
raise ValueError("انبار مبدأ و مقصد نمی‌توانند یکسان باشند")
if t not in INVENTORY_TRANSACTION_TYPES:
raise ValueError(f"نوع تراکنش نامعتبر است: {t}")
return self
+313
View File
@@ -0,0 +1,313 @@
"""Pydantic v2 request schemas — quotation & procurement domain."""
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
QuoteStatus = Literal["offered", "accepted", "arrived", "rejected", "expired"]
TermsheetStatus = Literal["offered", "accepted", "rejected"]
DiscountType = Literal["none", "percent", "fixed"]
DecisionStatus = Literal["pending", "chosen", "rejected"]
# ---------------------------------------------------------------------------
# Suppliers / projects
# ---------------------------------------------------------------------------
class SupplierCreate(APIModel):
name: str = Field(min_length=1)
category_id: UUID # v2.6: 1 supplier → exactly 1 category (required)
location: str | None = None
phone: str | None = None
contact_person: str | None = None
tax_id: str | None = None
class SupplierUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
category_id: UUID | None = None
location: str | None = None
phone: str | None = None
contact_person: str | None = None
tax_id: str | None = None
class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id)."""
material_id: UUID
class ProjectCreate(APIModel):
name: str = Field(min_length=1)
description: str | None = None
start_date: date | None = None
class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
description: str | None = None
start_date: date | None = None
# ---------------------------------------------------------------------------
# Quotes
# ---------------------------------------------------------------------------
class QuoteLineItemIn(APIModel):
temp_id: str = Field(min_length=1)
material_id: UUID
brand_material_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
notes: str | None = None
project_ids: list[UUID] | None = None # پروژه‌های تخصیص‌یافته به این قلم
class ScheduleStepIn(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date
amount: Decimal
note: str | None = None
class TermsheetIn(APIModel):
temp_id: str = Field(min_length=1)
label: str = Field(min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(default_factory=list)
class ItemTermsheetLinkIn(APIModel):
line_item_temp_id: str = Field(min_length=1)
termsheet_temp_id: str = Field(min_length=1)
unit_price: Decimal = Field(gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class QuoteCreate(APIModel):
supplier_id: UUID
project_id: UUID | None = None
project_ids: list[UUID] = Field(default_factory=list) # چند پروژه برای یک پیشنهاد
quote_date: datetime | None = None
expiry_date: date | None = None
is_official: bool = True
notes: str | None = None
# اقلام/شرایط پرداخت/قیمت‌ها می‌توانند خالی باشند — کاربر ممکن است بخواهد
# پیشنهادی ناقص را ذخیره کند (task: عدم اجبار تکمیل فیلدها)
line_items: list[QuoteLineItemIn] = Field(default_factory=list)
termsheets: list[TermsheetIn] = Field(default_factory=list)
item_termsheets: list[ItemTermsheetLinkIn] = Field(default_factory=list)
class QuoteChildrenReplaceIn(APIModel):
"""Full replacement of a quote's line items + termsheets (edit modal)."""
line_items: list[QuoteLineItemIn] = Field(default_factory=list)
termsheets: list[TermsheetIn] = Field(default_factory=list)
item_termsheets: list[ItemTermsheetLinkIn] = Field(default_factory=list)
class QuoteUpdate(APIModel):
supplier_id: UUID | None = None
project_id: UUID | None = None
project_ids: list[UUID] | None = None
quote_date: datetime | None = None
expiry_date: date | None = None
is_official: bool | None = None
notes: str | None = None
status: QuoteStatus | None = None
class AcceptTermsheetIn(APIModel):
id: UUID | None = None
label: str | None = Field(default=None, min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ItemDecisionIn(APIModel):
line_item_id: UUID
status: Literal["chosen", "rejected"]
accepted_termsheet_index: int | None = Field(default=None, ge=0)
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
class ItemProjectsIn(APIModel):
line_item_id: UUID
project_ids: list[UUID] = Field(default_factory=list)
class AcceptQuoteIn(APIModel):
accepted_termsheets: list[AcceptTermsheetIn] = Field(min_length=1)
item_decisions: list[ItemDecisionIn] = Field(min_length=1)
item_projects: list[ItemProjectsIn] = Field(default_factory=list) # تخصیص پروژه به هر قلم
class ArriveItemIn(APIModel):
line_item_id: UUID
received_qty: Decimal = Field(gt=0)
received_at: datetime | None = None
on_time: bool | None = None
quality_note: str | None = None
class ArriveItemsIn(APIModel):
items: list[ArriveItemIn] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# v2.6 — PM arrival confirmation (item_decisions.project_id + allocations)
# ---------------------------------------------------------------------------
class ReceiptAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class PMReceiptCreate(APIModel):
"""PM confirms a delivery: receipt + warehouse split (free count).
Allocations must sum exactly to received_qty and every warehouse must
belong to item_decisions.project_id.
"""
item_decision_id: UUID
received_qty: Decimal = Field(gt=0)
allocations: list[ReceiptAllocationIn] = Field(min_length=1)
received_at: datetime | None = None
on_time: bool | None = None
quality_note: str | None = None
class UserRoleUpdate(APIModel):
role: Literal["pm", "procurement_manager", "admin"]
class RejectQuoteIn(APIModel):
reason: str | None = None
class NoteEventIn(APIModel):
event_type: Literal["note"] = "note"
payload: dict = Field(default_factory=dict)
actor: str | None = None
# ---------------------------------------------------------------------------
# Line items / specs
# ---------------------------------------------------------------------------
class LineItemCreate(APIModel):
material_id: UUID
brand_material_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
notes: str | None = None
class LineItemUpdate(APIModel):
material_id: UUID | None = None
brand_material_id: UUID | None = None
unit_id: int | None = None
quantity: Decimal | None = Field(default=None, gt=0)
notes: str | None = None
class SpecIn(APIModel):
attribute_definition_id: UUID
value: int | float | str | bool | None = None
unit_id_at_time: int | None = None
class SpecsPutIn(APIModel):
specs: list[SpecIn] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Termsheets / schedule steps
# ---------------------------------------------------------------------------
class TermsheetCreate(APIModel):
label: str = Field(min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class TermsheetUpdate(APIModel):
label: str | None = Field(default=None, min_length=1)
discount_type: DiscountType | None = None
discount_value: Decimal | None = None
class TermsheetStatusUpdate(APIModel):
status: Literal["accepted", "rejected"]
class ScheduleStepCreate(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date
amount: Decimal
note: str | None = None
class ScheduleStepUpdate(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date | None = None
amount: Decimal | None = None
note: str | None = None
# ---------------------------------------------------------------------------
# Item termsheets / decisions / receipts
# ---------------------------------------------------------------------------
class ItemTermsheetCreate(APIModel):
termsheet_id: UUID
unit_price: Decimal = Field(gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class ItemTermsheetUpdate(APIModel):
unit_price: Decimal | None = Field(default=None, gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class DecisionPut(APIModel):
chosen_item_termsheet_id: UUID | None = None
status: Literal["chosen", "rejected"]
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
class DecisionStatusUpdate(APIModel):
status: Literal["chosen", "rejected"]
class DecisionPatch(APIModel):
chosen_item_termsheet_id: UUID | None = None
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
class ReceiptCreate(APIModel):
received_at: datetime | None = None
received_qty: Decimal = Field(gt=0)
on_time: bool | None = None
quality_note: str | None = None
class ReceiptUpdate(APIModel):
received_at: datetime | None = None
received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None
quality_note: str | None = None