619 lines
18 KiB
Python
619 lines
18 KiB
Python
from pydantic import BaseModel, Field, model_validator
|
|
from typing import Optional, List, Dict, Any
|
|
from decimal import Decimal
|
|
from datetime import datetime, date
|
|
|
|
# --- Brand ---
|
|
class BrandCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
logo_url: Optional[str] = Field(None, max_length=500)
|
|
device_types: Optional[List[str]] = None
|
|
|
|
class BrandUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
logo_url: Optional[str] = Field(None, max_length=500)
|
|
is_active: Optional[bool] = None
|
|
device_types: Optional[List[str]] = None
|
|
|
|
class BrandResponse(BaseModel):
|
|
brand_id: str
|
|
name: str
|
|
slug: str
|
|
logo_url: Optional[str]
|
|
is_active: bool
|
|
device_types: List[str] = []
|
|
created_at: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Category ---
|
|
class CategoryCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
parent_category_id: Optional[str] = Field(None, min_length=26, max_length=26)
|
|
description: Optional[str] = Field(None, max_length=500)
|
|
image_url: Optional[str] = Field(None, max_length=500)
|
|
sort_order: Optional[str] = "0"
|
|
is_parent_feature: bool = False
|
|
|
|
class CategoryUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
parent_category_id: Optional[str] = Field(None, min_length=26, max_length=26)
|
|
description: Optional[str] = Field(None, max_length=500)
|
|
image_url: Optional[str] = Field(None, max_length=500)
|
|
sort_order: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
is_parent_feature: Optional[bool] = None
|
|
|
|
class CategoryResponse(BaseModel):
|
|
category_id: str
|
|
parent_category_id: Optional[str]
|
|
name: str
|
|
slug: str
|
|
description: Optional[str]
|
|
image_url: Optional[str]
|
|
sort_order: str
|
|
is_parent_feature: bool = False
|
|
is_active: bool
|
|
product_count: Optional[int] = 0
|
|
created_at: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Category Parent Hierarchy (nested response) ---
|
|
class HierarchyModelItem(BaseModel):
|
|
model_id: str
|
|
name: str
|
|
slug: str
|
|
image_url: Optional[str] = None
|
|
|
|
class HierarchySeriesItem(BaseModel):
|
|
series_id: str
|
|
name: str
|
|
slug: str
|
|
models: List[HierarchyModelItem] = []
|
|
|
|
class HierarchyBrandItem(BaseModel):
|
|
brand_id: str
|
|
name: str
|
|
slug: str
|
|
logo_url: Optional[str] = None
|
|
series: List[HierarchySeriesItem] = []
|
|
|
|
class CategoryParentHierarchyResponse(BaseModel):
|
|
category_id: str
|
|
brands: List[HierarchyBrandItem] = []
|
|
|
|
# --- Tag ---
|
|
class TagCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
|
|
class TagResponse(BaseModel):
|
|
tag_id: str
|
|
name: str
|
|
slug: str
|
|
is_active: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Collection ---
|
|
class CollectionCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
description: Optional[str] = Field(None, max_length=500)
|
|
|
|
class CollectionResponse(BaseModel):
|
|
collection_id: str
|
|
name: str
|
|
slug: str
|
|
description: Optional[str]
|
|
is_active: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- SEO Metadata ---
|
|
class SeoMetadataCreate(BaseModel):
|
|
entity_type: str = Field(..., description="product, category, brand, device_model, repair_service")
|
|
entity_id: str
|
|
meta_title: str = Field(..., max_length=255)
|
|
meta_description: str
|
|
canonical_url: Optional[str] = Field(None, max_length=500)
|
|
og_image: Optional[str] = Field(None, max_length=500)
|
|
schema_markup: Optional[Dict[str, Any]] = Field(None, alias="schema_json")
|
|
|
|
class Config:
|
|
populate_by_name = True
|
|
from_attributes = True
|
|
|
|
class SeoMetadataResponse(BaseModel):
|
|
seo_id: str
|
|
entity_type: str
|
|
entity_id: str
|
|
meta_title: str
|
|
meta_description: str
|
|
canonical_url: Optional[str]
|
|
og_image: Optional[str]
|
|
schema_markup: Optional[Dict[str, Any]] = Field(None, alias="schema_json")
|
|
|
|
class Config:
|
|
populate_by_name = True
|
|
from_attributes = True
|
|
|
|
# --- Part ---
|
|
class PartCreate(BaseModel):
|
|
sku: str = Field(..., min_length=1, max_length=100)
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
cost_price: Decimal
|
|
low_stock_alert: Optional[int] = 3
|
|
supplier: Optional[str] = Field(None, max_length=255)
|
|
barcode: Optional[str] = Field(None, max_length=100)
|
|
|
|
class PartUpdate(BaseModel):
|
|
sku: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
cost_price: Optional[Decimal] = None
|
|
low_stock_alert: Optional[int] = None
|
|
supplier: Optional[str] = None
|
|
barcode: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class PartResponse(BaseModel):
|
|
part_id: str
|
|
sku: str
|
|
name: str
|
|
cost_price: Decimal
|
|
low_stock_alert: int
|
|
supplier: Optional[str]
|
|
barcode: Optional[str]
|
|
is_active: bool
|
|
stock: int = 0 # computed from stock ledger
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Part Compatibility ---
|
|
class PartDeviceCompatibilityCreate(BaseModel):
|
|
part_id: str
|
|
model_id: str
|
|
|
|
class PartDeviceCompatibilityResponse(BaseModel):
|
|
id: str
|
|
part_id: str
|
|
model_id: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Stock Movement ---
|
|
class StockMovementCreate(BaseModel):
|
|
entity_type: str = Field(..., description="variant or part")
|
|
entity_id: str
|
|
movement_type: str = Field(..., description="Purchase, Sale, Repair, Adjustment, Return, Transfer, Damage")
|
|
quantity: int
|
|
reference_type: str = Field(..., description="Order, PurchaseOrder, RepairBooking, ManualAdjustment")
|
|
reference_id: str
|
|
|
|
class StockMovementResponse(BaseModel):
|
|
movement_id: str
|
|
entity_type: str
|
|
entity_id: str
|
|
movement_type: str
|
|
quantity: int
|
|
reference_type: str
|
|
reference_id: str
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Purchase Order ---
|
|
class PurchaseOrderItemCreate(BaseModel):
|
|
part_id: str
|
|
quantity_ordered: int
|
|
unit_price: Decimal
|
|
|
|
class PurchaseOrderCreate(BaseModel):
|
|
supplier_name: str = Field(..., min_length=1, max_length=255)
|
|
items: List[PurchaseOrderItemCreate]
|
|
|
|
class PurchaseOrderUpdate(BaseModel):
|
|
status: Optional[str] = None # Draft, Ordered, Received, Cancelled
|
|
items_received: Optional[List[Dict[str, Any]]] = None # list of {"id": item_id, "quantity_received": int}
|
|
|
|
class PurchaseOrderItemResponse(BaseModel):
|
|
id: str
|
|
part_id: str
|
|
quantity_ordered: int
|
|
quantity_received: int
|
|
unit_price: Decimal
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class PurchaseOrderResponse(BaseModel):
|
|
purchase_order_id: str
|
|
po_number: str
|
|
supplier_name: str
|
|
status: str
|
|
total_amount: Decimal
|
|
created_at: datetime
|
|
items: List[PurchaseOrderItemResponse]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Device Series ---
|
|
class DeviceSeriesCreate(BaseModel):
|
|
brand_id: str
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
device_type: Optional[str] = None # laptop, tablet, mobile
|
|
sort_order: Optional[int] = 0
|
|
|
|
class DeviceSeriesUpdate(BaseModel):
|
|
brand_id: Optional[str] = None
|
|
name: Optional[str] = None
|
|
device_type: Optional[str] = None
|
|
sort_order: Optional[int] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class DeviceSeriesResponse(BaseModel):
|
|
series_id: str
|
|
brand_id: Optional[str] = None
|
|
name: str
|
|
slug: str
|
|
device_type: Optional[str] = None
|
|
sort_order: int
|
|
is_active: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Device Model ---
|
|
class DeviceModelCreate(BaseModel):
|
|
series_id: Optional[str] = None
|
|
brand_id: Optional[str] = None
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
device_type: Optional[str] = None # laptop, tablet, mobile
|
|
release_year: Optional[int] = None
|
|
image_url: Optional[str] = Field(None, max_length=500)
|
|
|
|
class DeviceModelUpdate(BaseModel):
|
|
series_id: Optional[str] = None
|
|
brand_id: Optional[str] = None
|
|
name: Optional[str] = None
|
|
device_type: Optional[str] = None
|
|
release_year: Optional[int] = None
|
|
image_url: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class DeviceModelResponse(BaseModel):
|
|
model_id: str
|
|
series_id: Optional[str] = None
|
|
brand_id: Optional[str] = None
|
|
name: str
|
|
slug: str
|
|
device_type: Optional[str] = None
|
|
full_path: str
|
|
release_year: Optional[int]
|
|
image_url: Optional[str]
|
|
is_active: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Service Type ---
|
|
class ServiceTypeCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
icon_url: Optional[str] = Field(None, max_length=500)
|
|
description: Optional[str] = Field(None, max_length=500)
|
|
|
|
class ServiceTypeUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
icon_url: Optional[str] = None
|
|
description: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
class ServiceTypeResponse(BaseModel):
|
|
service_type_id: str
|
|
name: str
|
|
slug: str
|
|
icon_url: Optional[str]
|
|
description: Optional[str]
|
|
is_active: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Repair Service ---
|
|
class RepairServiceCreate(BaseModel):
|
|
model_id: str
|
|
service_type_id: str
|
|
description: Optional[str] = Field(None, max_length=1000)
|
|
|
|
class RepairServiceUpdate(BaseModel):
|
|
model_id: Optional[str] = None
|
|
service_type_id: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
class RepairServiceResponse(BaseModel):
|
|
repair_service_id: str
|
|
model_id: str
|
|
service_type_id: str
|
|
slug: str
|
|
full_path: str
|
|
description: Optional[str]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Repair Variant & BOM Part ---
|
|
class RepairVariantPartCreate(BaseModel):
|
|
part_id: str
|
|
quantity: Optional[int] = 1
|
|
|
|
class RepairVariantCreate(BaseModel):
|
|
repair_service_id: str
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
price: Decimal
|
|
cost: Optional[Decimal] = Decimal(0.0)
|
|
duration_minutes: Optional[int] = 45
|
|
warranty_days: Optional[int] = 90
|
|
parts: Optional[List[RepairVariantPartCreate]] = []
|
|
|
|
class RepairVariantUpdate(BaseModel):
|
|
repair_service_id: Optional[str] = None
|
|
name: Optional[str] = None
|
|
price: Optional[Decimal] = None
|
|
cost: Optional[Decimal] = None
|
|
duration_minutes: Optional[int] = None
|
|
warranty_days: Optional[int] = None
|
|
parts: Optional[List[RepairVariantPartCreate]] = None
|
|
status: Optional[str] = None
|
|
|
|
class RepairVariantPartResponse(BaseModel):
|
|
id: str
|
|
variant_id: str
|
|
part_id: str
|
|
quantity: int
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class RepairVariantResponse(BaseModel):
|
|
variant_id: str
|
|
repair_service_id: str
|
|
name: str
|
|
price: Decimal
|
|
cost: Decimal
|
|
duration_minutes: int
|
|
warranty_days: int
|
|
status: str
|
|
created_at: datetime
|
|
bom_parts: List[RepairVariantPartResponse] = []
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Attribute Type ---
|
|
class AttributeTypeCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
code: str = Field(..., min_length=1, max_length=100)
|
|
preset_values: Optional[List[str]] = []
|
|
|
|
class AttributeTypeUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
code: Optional[str] = None
|
|
preset_values: Optional[List[str]] = None
|
|
|
|
class AttributeTypeResponse(BaseModel):
|
|
attribute_id: str
|
|
name: str
|
|
code: str
|
|
status: str
|
|
preset_values: Optional[List[str]] = []
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Variant Attribute ---
|
|
class VariantAttributeCreate(BaseModel):
|
|
attribute_id: str
|
|
attribute_value: str = Field(..., min_length=1, max_length=255)
|
|
|
|
class VariantAttributeResponse(BaseModel):
|
|
id: str
|
|
variant_id: str
|
|
attribute_id: str
|
|
attribute_value: str
|
|
attribute_name: Optional[str] = None
|
|
attribute_code: Optional[str] = None
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def resolve_attribute_type(cls, data: Any) -> Any:
|
|
if not isinstance(data, dict) and hasattr(data, "attribute_type") and data.attribute_type:
|
|
name = data.attribute_type.name or ""
|
|
code = data.attribute_type.code or ""
|
|
norm_code = code.lower().strip()
|
|
norm_name = name.lower().strip()
|
|
if norm_code in ("qul", "qual") or norm_name in ("qul", "qual"):
|
|
name = "Quality"
|
|
elif norm_code == "clr" or norm_name == "clr":
|
|
name = "Color"
|
|
elif norm_code == "str" or norm_name == "str":
|
|
name = "Storage"
|
|
return {
|
|
"id": data.id,
|
|
"variant_id": data.variant_id,
|
|
"attribute_id": data.attribute_id,
|
|
"attribute_value": data.attribute_value,
|
|
"attribute_name": name,
|
|
"attribute_code": code
|
|
}
|
|
elif isinstance(data, dict):
|
|
name = data.get("attribute_name") or ""
|
|
code = data.get("attribute_code") or ""
|
|
norm_code = str(code).lower().strip()
|
|
norm_name = str(name).lower().strip()
|
|
if norm_code in ("qul", "qual") or norm_name in ("qul", "qual"):
|
|
data["attribute_name"] = "Quality"
|
|
elif norm_code == "clr" or norm_name == "clr":
|
|
data["attribute_name"] = "Color"
|
|
elif norm_code == "str" or norm_name == "str":
|
|
data["attribute_name"] = "Storage"
|
|
return data
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Product Image ---
|
|
class ProductImageCreate(BaseModel):
|
|
image_url: str = Field(..., max_length=500)
|
|
alt_text: Optional[str] = Field(None, max_length=255)
|
|
sort_order: Optional[int] = 0
|
|
is_banner: Optional[bool] = False
|
|
|
|
class ProductImageResponse(BaseModel):
|
|
image_id: str
|
|
product_id: str
|
|
image_url: str
|
|
alt_text: Optional[str]
|
|
sort_order: int
|
|
is_banner: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Variant Image ---
|
|
class VariantImageCreate(BaseModel):
|
|
image_url: str = Field(..., max_length=500)
|
|
sort_order: Optional[int] = 0
|
|
is_primary: Optional[bool] = False
|
|
|
|
class VariantImageResponse(BaseModel):
|
|
image_id: str
|
|
variant_id: str
|
|
image_url: str
|
|
sort_order: int
|
|
is_primary: bool
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Product Variant ---
|
|
class ProductVariantCreate(BaseModel):
|
|
sku: str = Field(..., min_length=1, max_length=100)
|
|
barcode: Optional[str] = Field(None, max_length=100)
|
|
price: Decimal
|
|
compare_price: Optional[Decimal] = None
|
|
cost_price: Decimal
|
|
low_stock_threshold: Optional[int] = 5
|
|
initial_stock: Optional[int] = Field(None, ge=0, description="Sellable units; create defaults to 0, update omitted means leave ledger unchanged")
|
|
attributes: List[VariantAttributeCreate]
|
|
images: Optional[List[VariantImageCreate]] = []
|
|
|
|
class ProductVariantResponse(BaseModel):
|
|
variant_id: str
|
|
product_id: str
|
|
sku: str
|
|
barcode: Optional[str]
|
|
price: Decimal
|
|
compare_price: Optional[Decimal]
|
|
cost_price: Decimal
|
|
low_stock_threshold: int
|
|
status: str
|
|
available_stock: int = 0
|
|
attributes: List[VariantAttributeResponse] = []
|
|
images: List[VariantImageResponse] = []
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
# --- Product ---
|
|
class ProductCreate(BaseModel):
|
|
category_id: str
|
|
brand_id: Optional[str] = None
|
|
device_series_id: Optional[str] = None
|
|
device_model_id: Optional[str] = None
|
|
device_type: Optional[str] = None # laptop, tablet, mobile
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
description: Optional[str] = Field(None, max_length=20000)
|
|
warranty_type: Optional[str] = Field(None, max_length=100)
|
|
warranty_summary: Optional[str] = Field(None, max_length=500)
|
|
seo_title: Optional[str] = Field(None, max_length=20000)
|
|
seo_description: Optional[str] = Field(None, max_length=20000)
|
|
meta_keywords: Optional[str] = Field(None, max_length=20000)
|
|
show_specifications: Optional[bool] = Field(default=True)
|
|
images: Optional[List[ProductImageCreate]] = []
|
|
variants: List[ProductVariantCreate]
|
|
|
|
class ProductResponse(BaseModel):
|
|
product_id: str
|
|
category_id: str
|
|
brand_id: Optional[str]
|
|
brand_name: Optional[str] = None
|
|
device_series_id: Optional[str]
|
|
device_model_id: Optional[str]
|
|
device_type: Optional[str] = None
|
|
name: str
|
|
slug: str
|
|
full_path: str
|
|
description: Optional[str]
|
|
warranty_type: Optional[str] = None
|
|
warranty_summary: Optional[str] = None
|
|
seo_title: Optional[str] = None
|
|
seo_description: Optional[str] = None
|
|
meta_keywords: Optional[str] = None
|
|
show_specifications: bool = True
|
|
status: str
|
|
created_at: Optional[datetime] = None
|
|
images: List[ProductImageResponse] = []
|
|
variants: List[ProductVariantResponse] = []
|
|
|
|
@model_validator(mode="after")
|
|
def populate_images(self) -> 'ProductResponse':
|
|
if not self.images and self.variants:
|
|
first_var = self.variants[0]
|
|
if first_var.images:
|
|
self.images = [
|
|
ProductImageResponse(
|
|
image_id=vi.image_id,
|
|
product_id=self.product_id,
|
|
image_url=vi.image_url,
|
|
alt_text=self.name,
|
|
sort_order=vi.sort_order,
|
|
is_banner=vi.is_primary
|
|
) for vi in first_var.images
|
|
]
|
|
return self
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class ProductCardResponse(BaseModel):
|
|
product_id: str
|
|
category_id: Optional[str] = None
|
|
slug: str
|
|
name: str
|
|
thumbnail_url: Optional[str] = None
|
|
price: Decimal
|
|
compare_price: Optional[Decimal] = None
|
|
discount_percent: int = 0
|
|
rating: float = 5.0
|
|
stock_count: int = 10
|
|
first_variant_id: Optional[str] = None
|
|
badge: Optional[str] = None
|
|
brand_name: Optional[str] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class ProductPaginatedResponse(BaseModel):
|
|
total: int
|
|
page: int
|
|
limit: int
|
|
cursor: Optional[str] = None
|
|
products: List[ProductCardResponse]
|
|
|