diff --git a/hesabixAPI/adapters/api/v1/__init__.py b/hesabixAPI/adapters/api/v1/__init__.py
index e69de29..d8d30c8 100644
--- a/hesabixAPI/adapters/api/v1/__init__.py
+++ b/hesabixAPI/adapters/api/v1/__init__.py
@@ -0,0 +1,5 @@
+from .health import router as health # noqa: F401
+from .categories import router as categories # noqa: F401
+from .products import router as products # noqa: F401
+from .price_lists import router as price_lists # noqa: F401
+
diff --git a/hesabixAPI/adapters/api/v1/categories.py b/hesabixAPI/adapters/api/v1/categories.py
new file mode 100644
index 0000000..6290dbc
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/categories.py
@@ -0,0 +1,148 @@
+from typing import Any, Dict
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError
+from adapters.db.repositories.category_repository import CategoryRepository
+
+
+router = APIRouter(prefix="/categories", tags=["categories"])
+
+
+@router.post("/business/{business_id}/tree")
+@require_business_access("business_id")
+def get_categories_tree(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ # اجازه مشاهده نیاز به view روی سکشن categories دارد
+ if not ctx.can_read_section("categories"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
+ repo = CategoryRepository(db)
+ # درخت سراسری: بدون فیلتر نوع
+ tree = repo.get_tree(business_id, None)
+ # تبدیل کلید title به label به صورت بازگشتی
+ def _map_label(nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
+ mapped: list[Dict[str, Any]] = []
+ for n in nodes:
+ children = n.get("children") or []
+ mapped.append({
+ "id": n.get("id"),
+ "parent_id": n.get("parent_id"),
+ "label": n.get("title", ""),
+ "translations": n.get("translations", {}),
+ "children": _map_label(children) if isinstance(children, list) else [],
+ })
+ return mapped
+ items = _map_label(tree)
+ return success_response({"items": items}, request)
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "add"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.add", http_status=403)
+ parent_id = body.get("parent_id")
+ label: str = (body.get("label") or "").strip()
+ # ساخت ترجمهها از روی برچسب واحد
+ translations: Dict[str, str] = {"fa": label, "en": label} if label else {}
+ repo = CategoryRepository(db)
+ obj = repo.create_category(business_id=business_id, parent_id=parent_id, translations=translations)
+ item = {
+ "id": obj.id,
+ "parent_id": obj.parent_id,
+ "label": (obj.title_translations or {}).get(ctx.language)
+ or (obj.title_translations or {}).get("fa")
+ or (obj.title_translations or {}).get("en"),
+ "translations": obj.title_translations,
+ }
+ return success_response({"item": item}, request)
+
+
+@router.post("/business/{business_id}/update")
+@require_business_access("business_id")
+def update_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "edit"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403)
+ category_id = body.get("category_id")
+ label = body.get("label")
+ translations = {"fa": label, "en": label} if isinstance(label, str) and label.strip() else None
+ repo = CategoryRepository(db)
+ obj = repo.update_category(category_id=category_id, translations=translations)
+ if not obj:
+ raise ApiError("NOT_FOUND", "Category not found", http_status=404)
+ item = {
+ "id": obj.id,
+ "parent_id": obj.parent_id,
+ "label": (obj.title_translations or {}).get(ctx.language)
+ or (obj.title_translations or {}).get("fa")
+ or (obj.title_translations or {}).get("en"),
+ "translations": obj.title_translations,
+ }
+ return success_response({"item": item}, request)
+
+
+@router.post("/business/{business_id}/move")
+@require_business_access("business_id")
+def move_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "edit"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403)
+ category_id = body.get("category_id")
+ new_parent_id = body.get("new_parent_id")
+ repo = CategoryRepository(db)
+ obj = repo.move_category(category_id=category_id, new_parent_id=new_parent_id)
+ if not obj:
+ raise ApiError("NOT_FOUND", "Category not found", http_status=404)
+ item = {
+ "id": obj.id,
+ "parent_id": obj.parent_id,
+ "label": (obj.title_translations or {}).get(ctx.language)
+ or (obj.title_translations or {}).get("fa")
+ or (obj.title_translations or {}).get("en"),
+ "translations": obj.title_translations,
+ }
+ return success_response({"item": item}, request)
+
+
+@router.post("/business/{business_id}/delete")
+@require_business_access("business_id")
+def delete_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.delete", http_status=403)
+ repo = CategoryRepository(db)
+ category_id = body.get("category_id")
+ ok = repo.delete_category(category_id=category_id)
+ return success_response({"deleted": ok}, request)
+
+
diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py
index e6cc3c4..ac23487 100644
--- a/hesabixAPI/adapters/api/v1/persons.py
+++ b/hesabixAPI/adapters/api/v1/persons.py
@@ -57,12 +57,12 @@ router = APIRouter(prefix="/persons", tags=["persons"])
}
)
async def create_person_endpoint(
+ request: Request,
business_id: int,
person_data: PersonCreateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
- request: Request = None,
):
"""ایجاد شخص جدید برای کسب و کار"""
result = create_person(db, business_id, person_data)
@@ -104,11 +104,11 @@ async def create_person_endpoint(
}
)
async def get_persons_endpoint(
+ request: Request,
business_id: int,
query_info: QueryInfo,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
- request: Request = None,
):
"""دریافت لیست اشخاص کسب و کار"""
query_dict = {
@@ -572,11 +572,11 @@ async def download_persons_import_template(
}
)
async def get_person_endpoint(
+ request: Request,
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
- request: Request = None,
):
"""دریافت جزئیات شخص"""
# ابتدا باید business_id را از person دریافت کنیم
@@ -609,12 +609,12 @@ async def get_person_endpoint(
}
)
async def update_person_endpoint(
+ request: Request,
person_id: int,
person_data: PersonUpdateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
- request: Request = None,
):
"""ویرایش شخص"""
# ابتدا باید business_id را از person دریافت کنیم
@@ -647,11 +647,11 @@ async def update_person_endpoint(
}
)
async def delete_person_endpoint(
+ request: Request,
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
- request: Request = None,
):
"""حذف شخص"""
# ابتدا باید business_id را از person دریافت کنیم
@@ -677,11 +677,11 @@ async def delete_person_endpoint(
}
)
async def get_persons_summary_endpoint(
+ request: Request,
business_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
- request: Request = None,
):
"""دریافت خلاصه اشخاص کسب و کار"""
result = get_person_summary(db, business_id)
diff --git a/hesabixAPI/adapters/api/v1/price_lists.py b/hesabixAPI/adapters/api/v1/price_lists.py
new file mode 100644
index 0000000..958331b
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/price_lists.py
@@ -0,0 +1,163 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from typing import Dict, Any
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError, format_datetime_fields
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.price_list import (
+ PriceListCreateRequest,
+ PriceListUpdateRequest,
+ PriceItemUpsertRequest,
+)
+from app.services.price_list_service import (
+ create_price_list,
+ list_price_lists,
+ get_price_list,
+ update_price_list,
+ delete_price_list,
+ list_price_items,
+ upsert_price_item,
+ delete_price_item,
+)
+
+
+router = APIRouter(prefix="/price-lists", tags=["price-lists"])
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ payload: PriceListCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = create_price_list(db, business_id, payload)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_price_lists_endpoint(
+ request: Request,
+ business_id: int,
+ query: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ result = list_price_lists(db, business_id, {
+ "take": query.take,
+ "skip": query.skip,
+ "sort_by": query.sort_by,
+ "sort_desc": query.sort_desc,
+ "search": query.search,
+ })
+ return success_response(data=format_datetime_fields(result, request), request=request)
+
+
+@router.get("/business/{business_id}/{price_list_id}")
+@require_business_access("business_id")
+def get_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ item = get_price_list(db, business_id, price_list_id)
+ if not item:
+ raise ApiError("NOT_FOUND", "Price list not found", http_status=404)
+ return success_response(data=format_datetime_fields({"item": item}, request), request=request)
+
+
+@router.put("/business/{business_id}/{price_list_id}")
+@require_business_access("business_id")
+def update_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ payload: PriceListUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = update_price_list(db, business_id, price_list_id, payload)
+ if not result:
+ raise ApiError("NOT_FOUND", "Price list not found", http_status=404)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.delete("/business/{business_id}/{price_list_id}")
+@require_business_access("business_id")
+def delete_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+ ok = delete_price_list(db, business_id, price_list_id)
+ return success_response({"deleted": ok}, request)
+
+
+@router.post("/business/{business_id}/{price_list_id}/items")
+@require_business_access("business_id")
+def upsert_price_item_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ payload: PriceItemUpsertRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = upsert_price_item(db, business_id, price_list_id, payload)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.get("/business/{business_id}/{price_list_id}/items")
+@require_business_access("business_id")
+def list_price_items_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ result = list_price_items(db, business_id, price_list_id)
+ return success_response(data=format_datetime_fields(result, request), request=request)
+
+
+@router.delete("/business/{business_id}/items/{item_id}")
+@require_business_access("business_id")
+def delete_price_item_endpoint(
+ request: Request,
+ business_id: int,
+ item_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+ ok = delete_price_item(db, business_id, item_id)
+ return success_response({"deleted": ok}, request)
+
+
diff --git a/hesabixAPI/adapters/api/v1/product_attributes.py b/hesabixAPI/adapters/api/v1/product_attributes.py
new file mode 100644
index 0000000..c87b9f8
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/product_attributes.py
@@ -0,0 +1,124 @@
+from typing import Any, Dict
+
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError, format_datetime_fields
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.product_attribute import (
+ ProductAttributeCreateRequest,
+ ProductAttributeUpdateRequest,
+)
+from app.services.product_attribute_service import (
+ create_attribute,
+ list_attributes,
+ get_attribute,
+ update_attribute,
+ delete_attribute,
+)
+
+
+router = APIRouter(prefix="/product-attributes", tags=["product-attributes"])
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_product_attribute(
+ request: Request,
+ business_id: int,
+ payload: ProductAttributeCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("product_attributes", "add"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.add", http_status=403)
+ result = create_attribute(db, business_id, payload)
+ return success_response(
+ data=format_datetime_fields(result["data"], request),
+ request=request,
+ message=result.get("message"),
+ )
+
+
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_product_attributes(
+ request: Request,
+ business_id: int,
+ query: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("product_attributes"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403)
+
+ result = list_attributes(db, business_id, {
+ "take": query.take,
+ "skip": query.skip,
+ "sort_by": query.sort_by,
+ "sort_desc": query.sort_desc,
+ "search": query.search,
+ "filters": query.filters,
+ })
+ # Format all datetime fields in items/pagination
+ formatted = format_datetime_fields(result, request)
+ return success_response(data=formatted, request=request)
+
+
+@router.get("/business/{business_id}/{attribute_id}")
+@require_business_access("business_id")
+def get_product_attribute(
+ request: Request,
+ business_id: int,
+ attribute_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("product_attributes"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403)
+ item = get_attribute(db, attribute_id, business_id)
+ if not item:
+ raise ApiError("NOT_FOUND", "Attribute not found", http_status=404)
+ return success_response(data=format_datetime_fields({"item": item}, request), request=request)
+
+
+@router.put("/business/{business_id}/{attribute_id}")
+@require_business_access("business_id")
+def update_product_attribute(
+ request: Request,
+ business_id: int,
+ attribute_id: int,
+ payload: ProductAttributeUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("product_attributes", "edit"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.edit", http_status=403)
+ result = update_attribute(db, attribute_id, business_id, payload)
+ if not result:
+ raise ApiError("NOT_FOUND", "Attribute not found", http_status=404)
+ return success_response(
+ data=format_datetime_fields(result["data"], request),
+ request=request,
+ message=result.get("message"),
+ )
+
+
+@router.delete("/business/{business_id}/{attribute_id}")
+@require_business_access("business_id")
+def delete_product_attribute(
+ request: Request,
+ business_id: int,
+ attribute_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("product_attributes", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.delete", http_status=403)
+ ok = delete_attribute(db, attribute_id, business_id)
+ return success_response({"deleted": ok}, request)
+
+
diff --git a/hesabixAPI/adapters/api/v1/products.py b/hesabixAPI/adapters/api/v1/products.py
new file mode 100644
index 0000000..79d9fc7
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/products.py
@@ -0,0 +1,300 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from typing import Dict, Any
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError, format_datetime_fields
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.product import (
+ ProductCreateRequest,
+ ProductUpdateRequest,
+)
+from app.services.product_service import (
+ create_product,
+ list_products,
+ get_product,
+ update_product,
+ delete_product,
+)
+
+
+router = APIRouter(prefix="/products", tags=["products"])
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_product_endpoint(
+ request: Request,
+ business_id: int,
+ payload: ProductCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = create_product(db, business_id, payload)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_products_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ result = list_products(db, business_id, {
+ "take": query_info.take,
+ "skip": query_info.skip,
+ "sort_by": query_info.sort_by,
+ "sort_desc": query_info.sort_desc,
+ "search": query_info.search,
+ "filters": query_info.filters,
+ })
+ return success_response(data=format_datetime_fields(result, request), request=request)
+
+
+@router.get("/business/{business_id}/{product_id}")
+@require_business_access("business_id")
+def get_product_endpoint(
+ request: Request,
+ business_id: int,
+ product_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ item = get_product(db, product_id, business_id)
+ if not item:
+ raise ApiError("NOT_FOUND", "Product not found", http_status=404)
+ return success_response(data=format_datetime_fields({"item": item}, request), request=request)
+
+
+@router.put("/business/{business_id}/{product_id}")
+@require_business_access("business_id")
+def update_product_endpoint(
+ request: Request,
+ business_id: int,
+ product_id: int,
+ payload: ProductUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = update_product(db, product_id, business_id, payload)
+ if not result:
+ raise ApiError("NOT_FOUND", "Product not found", http_status=404)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.delete("/business/{business_id}/{product_id}")
+@require_business_access("business_id")
+def delete_product_endpoint(
+ request: Request,
+ business_id: int,
+ product_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+ ok = delete_product(db, product_id, business_id)
+ return success_response({"deleted": ok}, request)
+
+
+@router.post("/business/{business_id}/export/excel",
+ summary="خروجی Excel لیست محصولات",
+ description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستونها و ترتیب آنها",
+)
+@require_business_access("business_id")
+async def export_products_excel(
+ request: Request,
+ business_id: int,
+ body: dict,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+
+ query_dict = {
+ "take": int(body.get("take", 1000)),
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+ result = list_products(db, business_id, query_dict)
+ items = result.get("items", []) if isinstance(result, dict) else result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ export_columns = body.get("export_columns")
+ if export_columns and isinstance(export_columns, list):
+ headers = [col.get("label") or col.get("key") for col in export_columns]
+ keys = [col.get("key") for col in export_columns]
+ else:
+ default_cols = [
+ ("code", "کد"),
+ ("name", "نام"),
+ ("item_type", "نوع"),
+ ("category_id", "دسته"),
+ ("base_sales_price", "قیمت فروش"),
+ ("base_purchase_price", "قیمت خرید"),
+ ("main_unit_id", "واحد اصلی"),
+ ("secondary_unit_id", "واحد فرعی"),
+ ("track_inventory", "کنترل موجودی"),
+ ("created_at_formatted", "ایجاد"),
+ ]
+ keys = [k for k, _ in default_cols]
+ headers = [v for _, v in default_cols]
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Products"
+
+ # Header style
+ header_font = Font(bold=True)
+ header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
+ thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
+
+ ws.append(headers)
+ for cell in ws[1]:
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = Alignment(horizontal="center")
+ cell.border = thin_border
+
+ for it in items:
+ row = []
+ for k in keys:
+ row.append(it.get(k))
+ ws.append(row)
+ for cell in ws[ws.max_row]:
+ cell.border = thin_border
+
+ output = io.BytesIO()
+ wb.save(output)
+ data = output.getvalue()
+
+ return Response(
+ content=data,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": "attachment; filename=products.xlsx",
+ "Content-Length": str(len(data)),
+ },
+ )
+
+
+@router.post("/business/{business_id}/export/pdf",
+ summary="خروجی PDF لیست محصولات",
+ description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستونها",
+)
+@require_business_access("business_id")
+async def export_products_pdf(
+ request: Request,
+ business_id: int,
+ body: dict,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import datetime
+ from fastapi.responses import Response
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+
+ query_dict = {
+ "take": int(body.get("take", 100)),
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+ result = list_products(db, business_id, query_dict)
+ items = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ export_columns = body.get("export_columns")
+ if export_columns and isinstance(export_columns, list):
+ headers = [col.get("label") or col.get("key") for col in export_columns]
+ keys = [col.get("key") for col in export_columns]
+ else:
+ default_cols = [
+ ("code", "کد"),
+ ("name", "نام"),
+ ("item_type", "نوع"),
+ ("category_id", "دسته"),
+ ("base_sales_price", "قیمت فروش"),
+ ("base_purchase_price", "قیمت خرید"),
+ ("main_unit_id", "واحد اصلی"),
+ ("secondary_unit_id", "واحد فرعی"),
+ ("track_inventory", "کنترل موجودی"),
+ ("created_at_formatted", "ایجاد"),
+ ]
+ keys = [k for k, _ in default_cols]
+ headers = [v for _, v in default_cols]
+
+ # Build simple HTML table
+ head_html = """
+
+ """
+ title = "گزارش فهرست محصولات"
+ now = datetime.datetime.utcnow().isoformat()
+ header_row = "".join([f"
{h} | " for h in headers])
+ body_rows = "".join([
+ "" + "".join([f"| {(it.get(k) if it.get(k) is not None else '')} | " for k in keys]) + "
"
+ for it in items
+ ])
+ html = f"""
+ {head_html}
+ {title}
+ زمان تولید: {now}
+
+ {header_row}
+ {body_rows}
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 10mm; }")], font_config=font_config)
+
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": "attachment; filename=products.pdf",
+ "Content-Length": str(len(pdf_bytes)),
+ },
+ )
+
+
diff --git a/hesabixAPI/adapters/api/v1/schema_models/price_list.py b/hesabixAPI/adapters/api/v1/schema_models/price_list.py
new file mode 100644
index 0000000..f89a40e
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/schema_models/price_list.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from decimal import Decimal
+from pydantic import BaseModel, Field
+
+
+class PriceListCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ currency_id: Optional[int] = None
+ default_unit_id: Optional[int] = None
+ is_active: bool = True
+
+
+class PriceListUpdateRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ currency_id: Optional[int] = None
+ default_unit_id: Optional[int] = None
+ is_active: Optional[bool] = None
+
+
+class PriceItemUpsertRequest(BaseModel):
+ product_id: int
+ unit_id: Optional[int] = None
+ currency_id: Optional[int] = None
+ tier_name: str = Field(..., min_length=1, max_length=64)
+ min_qty: Decimal = Field(default=0)
+ price: Decimal
+
+
+class PriceListResponse(BaseModel):
+ id: int
+ business_id: int
+ name: str
+ currency_id: Optional[int] = None
+ default_unit_id: Optional[int] = None
+ is_active: bool
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
+class PriceItemResponse(BaseModel):
+ id: int
+ price_list_id: int
+ product_id: int
+ unit_id: Optional[int] = None
+ currency_id: Optional[int] = None
+ tier_name: str
+ min_qty: Decimal
+ price: Decimal
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/adapters/api/v1/schema_models/product.py b/hesabixAPI/adapters/api/v1/schema_models/product.py
new file mode 100644
index 0000000..129bf46
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/schema_models/product.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from decimal import Decimal
+from pydantic import BaseModel, Field
+from enum import Enum
+
+
+class ProductItemType(str, Enum):
+ PRODUCT = "کالا"
+ SERVICE = "خدمت"
+
+
+class ProductCreateRequest(BaseModel):
+ item_type: ProductItemType = Field(default=ProductItemType.PRODUCT)
+ code: Optional[str] = Field(default=None, max_length=64)
+ name: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=2000)
+ category_id: Optional[int] = None
+
+ main_unit_id: Optional[int] = None
+ secondary_unit_id: Optional[int] = None
+ unit_conversion_factor: Optional[Decimal] = None
+
+ base_sales_price: Optional[Decimal] = None
+ base_sales_note: Optional[str] = None
+ base_purchase_price: Optional[Decimal] = None
+ base_purchase_note: Optional[str] = None
+
+ track_inventory: bool = Field(default=False)
+ reorder_point: Optional[int] = None
+ min_order_qty: Optional[int] = None
+ lead_time_days: Optional[int] = None
+
+ is_sales_taxable: bool = Field(default=False)
+ is_purchase_taxable: bool = Field(default=False)
+ sales_tax_rate: Optional[Decimal] = None
+ purchase_tax_rate: Optional[Decimal] = None
+ tax_type_id: Optional[int] = None
+ tax_code: Optional[str] = Field(default=None, max_length=100)
+ tax_unit_id: Optional[int] = None
+
+ attribute_ids: Optional[List[int]] = Field(default=None, description="ویژگیهای انتخابی برای لینک شدن")
+
+
+class ProductUpdateRequest(BaseModel):
+ item_type: Optional[ProductItemType] = None
+ code: Optional[str] = Field(default=None, max_length=64)
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=2000)
+ category_id: Optional[int] = None
+
+ main_unit_id: Optional[int] = None
+ secondary_unit_id: Optional[int] = None
+ unit_conversion_factor: Optional[Decimal] = None
+
+ base_sales_price: Optional[Decimal] = None
+ base_sales_note: Optional[str] = None
+ base_purchase_price: Optional[Decimal] = None
+ base_purchase_note: Optional[str] = None
+
+ track_inventory: Optional[bool] = None
+ reorder_point: Optional[int] = None
+ min_order_qty: Optional[int] = None
+ lead_time_days: Optional[int] = None
+
+ is_sales_taxable: Optional[bool] = None
+ is_purchase_taxable: Optional[bool] = None
+ sales_tax_rate: Optional[Decimal] = None
+ purchase_tax_rate: Optional[Decimal] = None
+ tax_type_id: Optional[int] = None
+ tax_code: Optional[str] = Field(default=None, max_length=100)
+ tax_unit_id: Optional[int] = None
+
+ attribute_ids: Optional[List[int]] = None
+
+
+class ProductResponse(BaseModel):
+ id: int
+ business_id: int
+ item_type: str
+ code: str
+ name: str
+ description: Optional[str] = None
+ category_id: Optional[int] = None
+ main_unit_id: Optional[int] = None
+ secondary_unit_id: Optional[int] = None
+ unit_conversion_factor: Optional[Decimal] = None
+ base_sales_price: Optional[Decimal] = None
+ base_sales_note: Optional[str] = None
+ base_purchase_price: Optional[Decimal] = None
+ base_purchase_note: Optional[str] = None
+ track_inventory: bool
+ reorder_point: Optional[int] = None
+ min_order_qty: Optional[int] = None
+ lead_time_days: Optional[int] = None
+ is_sales_taxable: bool
+ is_purchase_taxable: bool
+ sales_tax_rate: Optional[Decimal] = None
+ purchase_tax_rate: Optional[Decimal] = None
+ tax_type_id: Optional[int] = None
+ tax_code: Optional[str] = None
+ tax_unit_id: Optional[int] = None
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/adapters/api/v1/schema_models/product_attribute.py b/hesabixAPI/adapters/api/v1/schema_models/product_attribute.py
new file mode 100644
index 0000000..1af0a88
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/schema_models/product_attribute.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from pydantic import BaseModel, Field
+
+
+class ProductAttributeCreateRequest(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255, description="عنوان ویژگی")
+ description: Optional[str] = Field(default=None, description="توضیحات ویژگی")
+
+
+class ProductAttributeUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255, description="عنوان ویژگی")
+ description: Optional[str] = Field(default=None, description="توضیحات ویژگی")
+
+
+class ProductAttributeResponse(BaseModel):
+ id: int
+ business_id: int
+ title: str
+ description: Optional[str] = None
+ created_at: str
+ updated_at: str
+
+
+class ProductAttributeListResponse(BaseModel):
+ items: list[ProductAttributeResponse]
+ pagination: dict
+
+
diff --git a/hesabixAPI/adapters/api/v1/support/operator.py b/hesabixAPI/adapters/api/v1/support/operator.py
index d3aec63..45ade04 100644
--- a/hesabixAPI/adapters/api/v1/support/operator.py
+++ b/hesabixAPI/adapters/api/v1/support/operator.py
@@ -23,10 +23,10 @@ router = APIRouter()
@router.post("/tickets/search", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def search_operator_tickets(
+ request: Request,
query_info: QueryInfo = Body(...),
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""جستجو در تمام تیکتها برای اپراتور"""
ticket_repo = TicketRepository(db)
@@ -110,10 +110,10 @@ async def search_operator_tickets(
@router.get("/tickets/{ticket_id}", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def get_operator_ticket(
+ request: Request,
ticket_id: int,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""مشاهده تیکت برای اپراتور"""
ticket_repo = TicketRepository(db)
@@ -135,11 +135,11 @@ async def get_operator_ticket(
@router.put("/tickets/{ticket_id}/status", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def update_ticket_status(
+ request: Request,
ticket_id: int,
status_request: UpdateStatusRequest,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""تغییر وضعیت تیکت"""
ticket_repo = TicketRepository(db)
@@ -169,11 +169,11 @@ async def update_ticket_status(
@router.post("/tickets/{ticket_id}/assign", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def assign_ticket(
+ request: Request,
ticket_id: int,
assign_request: AssignTicketRequest,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""تخصیص تیکت به اپراتور"""
ticket_repo = TicketRepository(db)
@@ -198,11 +198,11 @@ async def assign_ticket(
@router.post("/tickets/{ticket_id}/messages", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def send_operator_message(
+ request: Request,
ticket_id: int,
message_request: CreateMessageRequest,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""ارسال پیام اپراتور به تیکت"""
ticket_repo = TicketRepository(db)
@@ -239,11 +239,11 @@ async def send_operator_message(
@router.post("/tickets/{ticket_id}/messages/search", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def search_operator_ticket_messages(
+ request: Request,
ticket_id: int,
query_info: QueryInfo = Body(...),
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""جستجو در پیامهای تیکت برای اپراتور"""
ticket_repo = TicketRepository(db)
diff --git a/hesabixAPI/adapters/api/v1/support/schemas.py b/hesabixAPI/adapters/api/v1/support/schemas.py
index 6cda665..0f4bcc9 100644
--- a/hesabixAPI/adapters/api/v1/support/schemas.py
+++ b/hesabixAPI/adapters/api/v1/support/schemas.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+# Removed __future__ annotations to fix OpenAPI schema generation
from datetime import datetime
from typing import Optional, List
diff --git a/hesabixAPI/adapters/api/v1/support/tickets.py b/hesabixAPI/adapters/api/v1/support/tickets.py
index 0670c26..8a98e21 100644
--- a/hesabixAPI/adapters/api/v1/support/tickets.py
+++ b/hesabixAPI/adapters/api/v1/support/tickets.py
@@ -1,4 +1,4 @@
-from __future__ import annotations
+# Removed __future__ annotations to fix OpenAPI schema generation
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Request
@@ -22,10 +22,10 @@ router = APIRouter()
@router.post("/search", response_model=SuccessResponse)
async def search_user_tickets(
+ request: Request,
query_info: QueryInfo,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""جستجو در تیکتهای کاربر"""
ticket_repo = TicketRepository(db)
@@ -96,10 +96,10 @@ async def search_user_tickets(
@router.post("", response_model=SuccessResponse)
async def create_ticket(
+ request: Request,
ticket_request: CreateTicketRequest,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""ایجاد تیکت جدید"""
ticket_repo = TicketRepository(db)
@@ -139,10 +139,10 @@ async def create_ticket(
@router.get("/{ticket_id}", response_model=SuccessResponse)
async def get_ticket(
+ request: Request,
ticket_id: int,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""مشاهده تیکت"""
ticket_repo = TicketRepository(db)
@@ -163,11 +163,11 @@ async def get_ticket(
@router.post("/{ticket_id}/messages", response_model=SuccessResponse)
async def send_message(
+ request: Request,
ticket_id: int,
message_request: CreateMessageRequest,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""ارسال پیام به تیکت"""
ticket_repo = TicketRepository(db)
@@ -199,11 +199,11 @@ async def send_message(
@router.post("/{ticket_id}/messages/search", response_model=SuccessResponse)
async def search_ticket_messages(
+ request: Request,
ticket_id: int,
query_info: QueryInfo,
current_user: AuthContext = Depends(get_current_user),
- db: Session = Depends(get_db),
- request: Request = None
+ db: Session = Depends(get_db)
):
"""جستجو در پیامهای تیکت"""
ticket_repo = TicketRepository(db)
diff --git a/hesabixAPI/adapters/api/v1/tax_types.py b/hesabixAPI/adapters/api/v1/tax_types.py
new file mode 100644
index 0000000..5360905
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/tax_types.py
@@ -0,0 +1,49 @@
+from typing import Dict, Any, List
+from fastapi import APIRouter, Depends, Request
+
+from adapters.api.v1.schemas import SuccessResponse
+from adapters.db.session import get_db # noqa: F401 (kept for consistency/future use)
+from app.core.responses import success_response
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from sqlalchemy.orm import Session # noqa: F401
+
+
+router = APIRouter(prefix="/tax-types", tags=["tax-types"])
+
+
+def _static_tax_types() -> List[Dict[str, Any]]:
+ titles = [
+ "دارو",
+ "دخانیات",
+ "موبایل",
+ "لوازم خانگی برقی",
+ "قطعات مصرفی و یدکی وسایل نقلیه",
+ "فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
+ "طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
+ "منسوجات و پوشاک",
+ "اسباب بازی",
+ "دام زنده، گوشت سفید و قرمز",
+ "محصولات اساسی کشاورزی",
+ "سایر کالا ها",
+ ]
+ return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
+
+
+@router.get(
+ "/business/{business_id}",
+ summary="لیست نوعهای مالیات",
+ description="دریافت لیست نوعهای مالیات (ثابت)",
+ response_model=SuccessResponse,
+)
+@require_business_access()
+def list_tax_types(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+) -> Dict[str, Any]:
+ # Currently returns a static list; later can be sourced from DB if needed
+ items = _static_tax_types()
+ return success_response(items, request)
+
+
diff --git a/hesabixAPI/adapters/api/v1/tax_units.py b/hesabixAPI/adapters/api/v1/tax_units.py
new file mode 100644
index 0000000..56dceb7
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/tax_units.py
@@ -0,0 +1,382 @@
+from fastapi import APIRouter, Depends, Request, HTTPException
+from sqlalchemy.orm import Session
+from typing import List, Optional
+from decimal import Decimal
+
+from adapters.db.session import get_db
+from adapters.db.models.tax_unit import TaxUnit
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from pydantic import BaseModel, Field
+
+
+router = APIRouter(prefix="/tax-units", tags=["tax-units"])
+
+
+class TaxUnitCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی")
+ code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی")
+ description: Optional[str] = Field(default=None, description="توضیحات")
+ tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
+ is_active: bool = Field(default=True, description="وضعیت فعال/غیرفعال")
+
+
+class TaxUnitUpdateRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام واحد مالیاتی")
+ code: Optional[str] = Field(default=None, min_length=1, max_length=64, description="کد واحد مالیاتی")
+ description: Optional[str] = Field(default=None, description="توضیحات")
+ tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
+ is_active: Optional[bool] = Field(default=None, description="وضعیت فعال/غیرفعال")
+
+
+class TaxUnitResponse(BaseModel):
+ id: int
+ business_id: int
+ name: str
+ code: str
+ description: Optional[str] = None
+ tax_rate: Optional[Decimal] = None
+ is_active: bool
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
+@router.get("/business/{business_id}",
+ summary="لیست واحدهای مالیاتی کسبوکار",
+ description="دریافت لیست واحدهای مالیاتی یک کسبوکار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست واحدهای مالیاتی با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست واحدهای مالیاتی دریافت شد",
+ "data": [
+ {
+ "id": 1,
+ "business_id": 1,
+ "name": "مالیات بر ارزش افزوده",
+ "code": "VAT",
+ "description": "مالیات بر ارزش افزوده 9 درصد",
+ "tax_rate": 9.0,
+ "is_active": True,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ ]
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "کسبوکار یافت نشد"
+ }
+ }
+)
+@require_business_access()
+def get_tax_units(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت لیست واحدهای مالیاتی یک کسبوکار"""
+
+ # Query tax units for the business
+ tax_units = db.query(TaxUnit).filter(
+ TaxUnit.business_id == business_id
+ ).order_by(TaxUnit.name).all()
+
+ # Convert to response format
+ tax_unit_dicts = []
+ for tax_unit in tax_units:
+ tax_unit_dict = {
+ "id": tax_unit.id,
+ "business_id": tax_unit.business_id,
+ "name": tax_unit.name,
+ "code": tax_unit.code,
+ "description": tax_unit.description,
+ "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
+ "is_active": tax_unit.is_active,
+ "created_at": tax_unit.created_at.isoformat(),
+ "updated_at": tax_unit.updated_at.isoformat()
+ }
+ tax_unit_dicts.append(format_datetime_fields(tax_unit_dict, request))
+
+ return success_response(tax_unit_dicts, request)
+
+
+@router.post("/business/{business_id}",
+ summary="ایجاد واحد مالیاتی جدید",
+ description="ایجاد یک واحد مالیاتی جدید برای کسبوکار",
+ response_model=SuccessResponse,
+ responses={
+ 201: {
+ "description": "واحد مالیاتی با موفقیت ایجاد شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "واحد مالیاتی با موفقیت ایجاد شد",
+ "data": {
+ "id": 1,
+ "business_id": 1,
+ "name": "مالیات بر ارزش افزوده",
+ "code": "VAT",
+ "description": "مالیات بر ارزش افزوده 9 درصد",
+ "tax_rate": 9.0,
+ "is_active": True,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "کسبوکار یافت نشد"
+ }
+ }
+)
+@require_business_access()
+def create_tax_unit(
+ request: Request,
+ business_id: int,
+ tax_unit_data: TaxUnitCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """ایجاد واحد مالیاتی جدید"""
+
+ # Check if code already exists for this business
+ existing_tax_unit = db.query(TaxUnit).filter(
+ TaxUnit.business_id == business_id,
+ TaxUnit.code == tax_unit_data.code
+ ).first()
+
+ if existing_tax_unit:
+ raise HTTPException(
+ status_code=400,
+ detail="کد واحد مالیاتی قبلاً استفاده شده است"
+ )
+
+ # Create new tax unit
+ tax_unit = TaxUnit(
+ business_id=business_id,
+ name=tax_unit_data.name,
+ code=tax_unit_data.code,
+ description=tax_unit_data.description,
+ tax_rate=tax_unit_data.tax_rate,
+ is_active=tax_unit_data.is_active
+ )
+
+ db.add(tax_unit)
+ db.commit()
+ db.refresh(tax_unit)
+
+ # Convert to response format
+ tax_unit_dict = {
+ "id": tax_unit.id,
+ "business_id": tax_unit.business_id,
+ "name": tax_unit.name,
+ "code": tax_unit.code,
+ "description": tax_unit.description,
+ "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
+ "is_active": tax_unit.is_active,
+ "created_at": tax_unit.created_at.isoformat(),
+ "updated_at": tax_unit.updated_at.isoformat()
+ }
+
+ formatted_response = format_datetime_fields(tax_unit_dict, request)
+
+ return success_response(formatted_response, request)
+
+
+@router.put("/{tax_unit_id}",
+ summary="بهروزرسانی واحد مالیاتی",
+ description="بهروزرسانی اطلاعات یک واحد مالیاتی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "واحد مالیاتی با موفقیت بهروزرسانی شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "واحد مالیاتی با موفقیت بهروزرسانی شد",
+ "data": {
+ "id": 1,
+ "business_id": 1,
+ "name": "مالیات بر ارزش افزوده",
+ "code": "VAT",
+ "description": "مالیات بر ارزش افزوده 9 درصد",
+ "tax_rate": 9.0,
+ "is_active": True,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "واحد مالیاتی یافت نشد"
+ }
+ }
+)
+@require_business_access()
+def update_tax_unit(
+ request: Request,
+ tax_unit_id: int,
+ tax_unit_data: TaxUnitUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """بهروزرسانی واحد مالیاتی"""
+
+ # Find the tax unit
+ tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
+ if not tax_unit:
+ raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
+
+ # Check business access
+ if tax_unit.business_id not in ctx.business_ids:
+ raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسبوکار")
+
+ # Check if new code conflicts with existing ones
+ if tax_unit_data.code and tax_unit_data.code != tax_unit.code:
+ existing_tax_unit = db.query(TaxUnit).filter(
+ TaxUnit.business_id == tax_unit.business_id,
+ TaxUnit.code == tax_unit_data.code,
+ TaxUnit.id != tax_unit_id
+ ).first()
+
+ if existing_tax_unit:
+ raise HTTPException(
+ status_code=400,
+ detail="کد واحد مالیاتی قبلاً استفاده شده است"
+ )
+
+ # Update fields
+ update_data = tax_unit_data.dict(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(tax_unit, field, value)
+
+ db.commit()
+ db.refresh(tax_unit)
+
+ # Convert to response format
+ tax_unit_dict = {
+ "id": tax_unit.id,
+ "business_id": tax_unit.business_id,
+ "name": tax_unit.name,
+ "code": tax_unit.code,
+ "description": tax_unit.description,
+ "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
+ "is_active": tax_unit.is_active,
+ "created_at": tax_unit.created_at.isoformat(),
+ "updated_at": tax_unit.updated_at.isoformat()
+ }
+
+ formatted_response = format_datetime_fields(tax_unit_dict, request)
+
+ return success_response(formatted_response, request)
+
+
+@router.delete("/{tax_unit_id}",
+ summary="حذف واحد مالیاتی",
+ description="حذف یک واحد مالیاتی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "واحد مالیاتی با موفقیت حذف شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "واحد مالیاتی با موفقیت حذف شد",
+ "data": None
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "واحد مالیاتی یافت نشد"
+ },
+ 409: {
+ "description": "امکان حذف واحد مالیاتی به دلیل استفاده در محصولات وجود ندارد"
+ }
+ }
+)
+@require_business_access()
+def delete_tax_unit(
+ request: Request,
+ tax_unit_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """حذف واحد مالیاتی"""
+
+ # Find the tax unit
+ tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
+ if not tax_unit:
+ raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
+
+ # Check business access
+ if tax_unit.business_id not in ctx.business_ids:
+ raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسبوکار")
+
+ # Check if tax unit is used in products
+ from adapters.db.models.product import Product
+ products_using_tax_unit = db.query(Product).filter(
+ Product.tax_unit_id == tax_unit_id
+ ).count()
+
+ if products_using_tax_unit > 0:
+ raise HTTPException(
+ status_code=409,
+ detail=f"امکان حذف واحد مالیاتی به دلیل استفاده در {products_using_tax_unit} محصول وجود ندارد"
+ )
+
+ # Delete the tax unit
+ db.delete(tax_unit)
+ db.commit()
+
+ return success_response(None, request)
diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py
index 610b9e5..a8d4d3c 100644
--- a/hesabixAPI/adapters/db/models/__init__.py
+++ b/hesabixAPI/adapters/db/models/__init__.py
@@ -30,3 +30,9 @@ from .currency import Currency, BusinessCurrency # noqa: F401
from .document import Document # noqa: F401
from .document_line import DocumentLine # noqa: F401
from .account import Account # noqa: F401
+from .category import BusinessCategory # noqa: F401
+from .product_attribute import ProductAttribute # noqa: F401
+from .product import Product # noqa: F401
+from .price_list import PriceList, PriceItem # noqa: F401
+from .product_attribute_link import ProductAttributeLink # noqa: F401
+from .tax_unit import TaxUnit # noqa: F401
diff --git a/hesabixAPI/adapters/db/models/category.py b/hesabixAPI/adapters/db/models/category.py
new file mode 100644
index 0000000..e9f4e73
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/category.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class BusinessCategory(Base):
+ """
+ دستهبندیهای کالا/خدمت برای هر کسبوکار با ساختار درختی
+ - عناوین چندزبانه در فیلد JSON `title_translations` نگهداری میشود
+ - نوع دستهبندی: product | service
+ """
+ __tablename__ = "categories"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
+ # فیلد type حذف شده است (در مهاجرت بعدی)
+ title_translations: Mapped[dict] = mapped_column(JSON, nullable=False, default={})
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ parent = relationship("BusinessCategory", remote_side=[id], backref="children")
+
+
diff --git a/hesabixAPI/adapters/db/models/price_list.py b/hesabixAPI/adapters/db/models/price_list.py
new file mode 100644
index 0000000..88944b7
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/price_list.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import (
+ String,
+ Integer,
+ DateTime,
+ ForeignKey,
+ UniqueConstraint,
+ Boolean,
+ Numeric,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class PriceList(Base):
+ __tablename__ = "price_lists"
+ __table_args__ = (
+ UniqueConstraint("business_id", "name", name="uq_price_lists_business_name"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
+ default_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
+class PriceItem(Base):
+ __tablename__ = "price_items"
+ __table_args__ = (
+ UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", name="uq_price_items_unique_tier"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True)
+ product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
+ unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
+ tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" )
+ min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0)
+ price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/adapters/db/models/product.py b/hesabixAPI/adapters/db/models/product.py
new file mode 100644
index 0000000..6188a74
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/product.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from enum import Enum
+
+from sqlalchemy import (
+ String,
+ Integer,
+ DateTime,
+ Text,
+ ForeignKey,
+ UniqueConstraint,
+ Boolean,
+ Numeric,
+ Enum as SQLEnum,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ProductItemType(str, Enum):
+ PRODUCT = "کالا"
+ SERVICE = "خدمت"
+
+
+class Product(Base):
+ """
+ موجودیت کالا/خدمت در سطح هر کسبوکار
+ - کد دستی/اتوماتیک یکتا در هر کسبوکار
+ - پشتیبانی از مالیات فروش/خرید، کنترل موجودی و واحدها
+ - اتصال به دستهبندیها و ویژگیها (ویژگیها از طریق جدول لینک)
+ """
+
+ __tablename__ = "products"
+ __table_args__ = (
+ UniqueConstraint("business_id", "code", name="uq_products_business_code"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ item_type: Mapped[ProductItemType] = mapped_column(
+ SQLEnum(ProductItemType, values_callable=lambda obj: [e.value for e in obj], name="product_item_type_enum"),
+ nullable=False,
+ default=ProductItemType.PRODUCT,
+ comment="نوع آیتم (کالا/خدمت)",
+ )
+
+ code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد یکتا در هر کسبوکار")
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ # دستهبندی (اختیاری)
+ category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
+
+ # واحدها
+ main_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True)
+
+ # قیمتهای پایه (نمایشی)
+ base_sales_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
+ base_sales_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ base_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
+ base_purchase_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ # کنترل موجودی
+ track_inventory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ reorder_point: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ min_order_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ lead_time_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+ # مالیات
+ is_sales_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ is_purchase_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ sales_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
+ purchase_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
+ tax_type_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ tax_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ tax_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/adapters/db/models/product_attribute.py b/hesabixAPI/adapters/db/models/product_attribute.py
new file mode 100644
index 0000000..2fec70d
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/product_attribute.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, Text, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ProductAttribute(Base):
+ """
+ ویژگیهای کالا/خدمت در سطح هر کسبوکار
+ - عنوان و توضیحات ساده (بدون چندزبانه)
+ - هر عنوان در هر کسبوکار یکتا باشد
+ """
+ __tablename__ = "product_attributes"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ title: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/adapters/db/models/product_attribute_link.py b/hesabixAPI/adapters/db/models/product_attribute_link.py
new file mode 100644
index 0000000..9d42847
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/product_attribute_link.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import Integer, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ProductAttributeLink(Base):
+ """لینک بین محصول و ویژگیها (چندبهچند)"""
+ __tablename__ = "product_attribute_links"
+ __table_args__ = (
+ UniqueConstraint("product_id", "attribute_id", name="uq_product_attribute_links_unique"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
+ attribute_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_attributes.id", ondelete="CASCADE"), nullable=False, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/adapters/db/models/tax_unit.py b/hesabixAPI/adapters/db/models/tax_unit.py
new file mode 100644
index 0000000..56be926
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/tax_unit.py
@@ -0,0 +1,24 @@
+from datetime import datetime
+from sqlalchemy import String, Integer, Boolean, DateTime, Text, Numeric
+from sqlalchemy.orm import Mapped, mapped_column
+from adapters.db.session import Base
+
+
+class TaxUnit(Base):
+ """
+ موجودیت واحد مالیاتی
+ - مدیریت واحدهای مالیاتی مختلف برای کسبوکارها
+ - پشتیبانی از انواع مختلف مالیات (فروش، خرید، ارزش افزوده و...)
+ """
+
+ __tablename__ = "tax_units"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="شناسه کسبوکار")
+ name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی")
+ code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
+ description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات")
+ tax_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="نرخ مالیات (درصد)")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال/غیرفعال")
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
diff --git a/hesabixAPI/adapters/db/repositories/category_repository.py b/hesabixAPI/adapters/db/repositories/category_repository.py
new file mode 100644
index 0000000..43ad61e
--- /dev/null
+++ b/hesabixAPI/adapters/db/repositories/category_repository.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+from typing import List, Dict, Any
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, or_
+
+from .base_repo import BaseRepository
+from ..models.category import BusinessCategory
+
+
+class CategoryRepository(BaseRepository[BusinessCategory]):
+ def __init__(self, db: Session):
+ super().__init__(db, BusinessCategory)
+
+ def get_tree(self, business_id: int, type_: str | None = None) -> list[Dict[str, Any]]:
+ stmt = select(BusinessCategory).where(BusinessCategory.business_id == business_id)
+ # درخت سراسری: نوع نادیده گرفته میشود (همه رکوردها)
+ stmt = stmt.order_by(BusinessCategory.sort_order.asc(), BusinessCategory.id.asc())
+ rows = list(self.db.execute(stmt).scalars().all())
+ flat = [
+ {
+ "id": r.id,
+ "parent_id": r.parent_id,
+ "translations": r.title_translations or {},
+ # برچسب واحد بر اساس زبان پیشفرض: ابتدا fa سپس en
+ "title": (r.title_translations or {}).get("fa")
+ or (r.title_translations or {}).get("en")
+ or "",
+ }
+ for r in rows
+ ]
+ return self._build_tree(flat)
+
+ def _build_tree(self, nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
+ by_id: dict[int, Dict[str, Any]] = {}
+ roots: list[Dict[str, Any]] = []
+ for n in nodes:
+ item = {
+ "id": n["id"],
+ "parent_id": n.get("parent_id"),
+ "title": n.get("title", ""),
+ "translations": n.get("translations", {}),
+ "children": [],
+ }
+ by_id[item["id"]] = item
+ for item in list(by_id.values()):
+ pid = item.get("parent_id")
+ if pid and pid in by_id:
+ by_id[pid]["children"].append(item)
+ else:
+ roots.append(item)
+ return roots
+
+ def create_category(self, *, business_id: int, parent_id: int | None, translations: dict[str, str]) -> BusinessCategory:
+ obj = BusinessCategory(
+ business_id=business_id,
+ parent_id=parent_id,
+ title_translations=translations or {},
+ )
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update_category(self, *, category_id: int, translations: dict[str, str] | None = None) -> BusinessCategory | None:
+ obj = self.db.get(BusinessCategory, category_id)
+ if not obj:
+ return None
+ if translations:
+ obj.title_translations = {**(obj.title_translations or {}), **translations}
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def move_category(self, *, category_id: int, new_parent_id: int | None) -> BusinessCategory | None:
+ obj = self.db.get(BusinessCategory, category_id)
+ if not obj:
+ return None
+ obj.parent_id = new_parent_id
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete_category(self, *, category_id: int) -> bool:
+ obj = self.db.get(BusinessCategory, category_id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+
diff --git a/hesabixAPI/adapters/db/repositories/price_list_repository.py b/hesabixAPI/adapters/db/repositories/price_list_repository.py
new file mode 100644
index 0000000..0c11600
--- /dev/null
+++ b/hesabixAPI/adapters/db/repositories/price_list_repository.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, func, and_, or_
+
+from .base_repo import BaseRepository
+from ..models.price_list import PriceList, PriceItem
+
+
+class PriceListRepository(BaseRepository[PriceList]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, PriceList)
+
+ def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None) -> dict[str, Any]:
+ stmt = select(PriceList).where(PriceList.business_id == business_id)
+ if search:
+ stmt = stmt.where(PriceList.name.ilike(f"%{search}%"))
+
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+ if sort_by in {"name", "created_at"}:
+ col = getattr(PriceList, sort_by)
+ stmt = stmt.order_by(col.desc() if sort_desc else col.asc())
+ else:
+ stmt = stmt.order_by(PriceList.id.desc() if sort_desc else PriceList.id.asc())
+
+ rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
+ items = [self._to_dict_list(pl) for pl in rows]
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def create(self, **data: Any) -> PriceList:
+ obj = PriceList(**data)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update(self, id: int, **data: Any) -> Optional[PriceList]:
+ obj = self.db.get(PriceList, id)
+ if not obj:
+ return None
+ for k, v in data.items():
+ if hasattr(obj, k) and v is not None:
+ setattr(obj, k, v)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, id: int) -> bool:
+ obj = self.db.get(PriceList, id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+ def _to_dict_list(self, pl: PriceList) -> dict[str, Any]:
+ return {
+ "id": pl.id,
+ "business_id": pl.business_id,
+ "name": pl.name,
+ "currency_id": pl.currency_id,
+ "default_unit_id": pl.default_unit_id,
+ "is_active": pl.is_active,
+ "created_at": pl.created_at,
+ "updated_at": pl.updated_at,
+ }
+
+
+class PriceItemRepository(BaseRepository[PriceItem]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, PriceItem)
+
+ def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0) -> dict[str, Any]:
+ stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id)
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+ rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
+ items = [self._to_dict(pi) for pi in rows]
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int | None, tier_name: str, min_qty, price) -> PriceItem:
+ # Try find existing unique combination
+ stmt = select(PriceItem).where(
+ and_(
+ PriceItem.price_list_id == price_list_id,
+ PriceItem.product_id == product_id,
+ PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id,
+ PriceItem.tier_name == tier_name,
+ PriceItem.min_qty == min_qty,
+ )
+ )
+ existing = self.db.execute(stmt).scalars().first()
+ if existing:
+ existing.price = price
+ if currency_id is not None:
+ existing.currency_id = currency_id
+ self.db.commit()
+ self.db.refresh(existing)
+ return existing
+ obj = PriceItem(
+ price_list_id=price_list_id,
+ product_id=product_id,
+ unit_id=unit_id,
+ currency_id=currency_id,
+ tier_name=tier_name,
+ min_qty=min_qty,
+ price=price,
+ )
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, id: int) -> bool:
+ obj = self.db.get(PriceItem, id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+ def _to_dict(self, pi: PriceItem) -> dict[str, Any]:
+ return {
+ "id": pi.id,
+ "price_list_id": pi.price_list_id,
+ "product_id": pi.product_id,
+ "unit_id": pi.unit_id,
+ "currency_id": pi.currency_id,
+ "tier_name": pi.tier_name,
+ "min_qty": pi.min_qty,
+ "price": pi.price,
+ "created_at": pi.created_at,
+ "updated_at": pi.updated_at,
+ }
+
+
diff --git a/hesabixAPI/adapters/db/repositories/product_attribute_repository.py b/hesabixAPI/adapters/db/repositories/product_attribute_repository.py
new file mode 100644
index 0000000..b61846e
--- /dev/null
+++ b/hesabixAPI/adapters/db/repositories/product_attribute_repository.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+from typing import Dict, Any, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+
+from .base_repo import BaseRepository
+from ..models.product_attribute import ProductAttribute
+
+
+class ProductAttributeRepository(BaseRepository[ProductAttribute]):
+ def __init__(self, db: Session):
+ super().__init__(db, ProductAttribute)
+
+ def search(
+ self,
+ *,
+ business_id: int,
+ take: int = 20,
+ skip: int = 0,
+ sort_by: str | None = None,
+ sort_desc: bool = True,
+ search: str | None = None,
+ filters: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ stmt = select(ProductAttribute).where(ProductAttribute.business_id == business_id)
+
+ if search:
+ stmt = stmt.where(ProductAttribute.title.ilike(f"%{search}%"))
+
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+
+ # Sorting
+ if sort_by == 'title':
+ order_col = ProductAttribute.title.desc() if sort_desc else ProductAttribute.title.asc()
+ stmt = stmt.order_by(order_col)
+ else:
+ order_col = ProductAttribute.id.desc() if sort_desc else ProductAttribute.id.asc()
+ stmt = stmt.order_by(order_col)
+
+ # Paging
+ stmt = stmt.offset(skip).limit(take)
+ rows = list(self.db.execute(stmt).scalars().all())
+
+ items: list[dict[str, Any]] = [
+ {
+ "id": r.id,
+ "business_id": r.business_id,
+ "title": r.title,
+ "description": r.description,
+ "created_at": r.created_at,
+ "updated_at": r.updated_at,
+ }
+ for r in rows
+ ]
+
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def create(self, *, business_id: int, title: str, description: str | None) -> ProductAttribute:
+ obj = ProductAttribute(business_id=business_id, title=title, description=description)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update(self, *, attribute_id: int, title: str | None, description: str | None) -> Optional[ProductAttribute]:
+ obj = self.db.get(ProductAttribute, attribute_id)
+ if not obj:
+ return None
+ if title is not None:
+ obj.title = title
+ if description is not None:
+ obj.description = description
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, *, attribute_id: int) -> bool:
+ obj = self.db.get(ProductAttribute, attribute_id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+
diff --git a/hesabixAPI/adapters/db/repositories/product_repository.py b/hesabixAPI/adapters/db/repositories/product_repository.py
new file mode 100644
index 0000000..db63dc2
--- /dev/null
+++ b/hesabixAPI/adapters/db/repositories/product_repository.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+
+from .base_repo import BaseRepository
+from ..models.product import Product
+
+
+class ProductRepository(BaseRepository[Product]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, Product)
+
+ def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None, filters: dict[str, Any] | None = None) -> dict[str, Any]:
+ stmt = select(Product).where(Product.business_id == business_id)
+
+ if search:
+ like = f"%{search}%"
+ stmt = stmt.where(
+ or_(
+ Product.name.ilike(like),
+ Product.code.ilike(like),
+ Product.description.ilike(like),
+ )
+ )
+
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+
+ # Sorting
+ if sort_by in {"name", "code", "created_at"}:
+ col = getattr(Product, sort_by)
+ stmt = stmt.order_by(col.desc() if sort_desc else col.asc())
+ else:
+ stmt = stmt.order_by(Product.id.desc() if sort_desc else Product.id.asc())
+
+ stmt = stmt.offset(skip).limit(take)
+ rows = list(self.db.execute(stmt).scalars().all())
+
+ def _to_dict(p: Product) -> dict[str, Any]:
+ return {
+ "id": p.id,
+ "business_id": p.business_id,
+ "item_type": p.item_type.value if hasattr(p.item_type, 'value') else str(p.item_type),
+ "code": p.code,
+ "name": p.name,
+ "description": p.description,
+ "category_id": p.category_id,
+ "main_unit_id": p.main_unit_id,
+ "secondary_unit_id": p.secondary_unit_id,
+ "unit_conversion_factor": p.unit_conversion_factor,
+ "base_sales_price": p.base_sales_price,
+ "base_sales_note": p.base_sales_note,
+ "base_purchase_price": p.base_purchase_price,
+ "base_purchase_note": p.base_purchase_note,
+ "track_inventory": p.track_inventory,
+ "reorder_point": p.reorder_point,
+ "min_order_qty": p.min_order_qty,
+ "lead_time_days": p.lead_time_days,
+ "is_sales_taxable": p.is_sales_taxable,
+ "is_purchase_taxable": p.is_purchase_taxable,
+ "sales_tax_rate": p.sales_tax_rate,
+ "purchase_tax_rate": p.purchase_tax_rate,
+ "tax_type_id": p.tax_type_id,
+ "tax_code": p.tax_code,
+ "tax_unit_id": p.tax_unit_id,
+ "created_at": p.created_at,
+ "updated_at": p.updated_at,
+ }
+
+ items = [_to_dict(r) for r in rows]
+
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def create(self, **data: Any) -> Product:
+ obj = Product(**data)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update(self, product_id: int, **data: Any) -> Optional[Product]:
+ obj = self.db.get(Product, product_id)
+ if not obj:
+ return None
+ for k, v in data.items():
+ if hasattr(obj, k) and v is not None:
+ setattr(obj, k, v)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, product_id: int) -> bool:
+ obj = self.db.get(Product, product_id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+
diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py
index 9a07894..1805506 100644
--- a/hesabixAPI/app/core/permissions.py
+++ b/hesabixAPI/app/core/permissions.py
@@ -1,7 +1,6 @@
-from __future__ import annotations
-
from functools import wraps
from typing import Callable, Any
+import inspect
from fastapi import Depends
from app.core.auth_dependency import get_current_user, AuthContext
@@ -70,44 +69,48 @@ def require_superadmin():
def require_business_access(business_id_param: str = "business_id"):
- """Decorator برای بررسی دسترسی به کسب و کار خاص"""
+ """Decorator برای بررسی دسترسی به کسب و کار خاص.
+ امضای اصلی endpoint حفظ میشود و Request از آرگومانها استخراج میگردد.
+ """
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
import logging
+ from fastapi import Request
logger = logging.getLogger(__name__)
-
- # Find request in args or kwargs
+
+ # یافتن Request در args/kwargs
request = None
for arg in args:
- if hasattr(arg, 'headers'): # Check if it's a Request object
+ if isinstance(arg, Request):
request = arg
break
-
- if not request and 'request' in kwargs:
- request = kwargs['request']
-
- if not request:
+ if request is None:
+ request = kwargs.get('request')
+ if request is None:
logger.error("Request not found in function arguments")
raise ApiError("INTERNAL_ERROR", "Request not found", http_status=500)
-
- # Get database session
+
+ # دسترسی به DB و کاربر
from adapters.db.session import get_db
db = next(get_db())
ctx = get_current_user(request, db)
+
+ # استخراج business_id از kwargs یا path params
business_id = kwargs.get(business_id_param)
-
- logger.info(f"Checking business access for user {ctx.get_user_id()} to business {business_id}")
- logger.info(f"User business_id from context: {ctx.business_id}")
- logger.info(f"User is superadmin: {ctx.is_superadmin()}")
- logger.info(f"User is business owner: {ctx.is_business_owner()}")
-
- if business_id and not ctx.can_access_business(business_id):
+ if business_id is None:
+ try:
+ business_id = request.path_params.get(business_id_param)
+ except Exception:
+ business_id = None
+
+ if business_id and not ctx.can_access_business(int(business_id)):
logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
-
- logger.info(f"User {ctx.get_user_id()} has access to business {business_id}")
+
return func(*args, **kwargs)
+ # Preserve original signature so FastAPI sees correct parameters (including Request)
+ wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
return wrapper
return decorator
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index 693edbb..c706a6a 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -11,7 +11,13 @@ from adapters.api.v1.currencies import router as currencies_router
from adapters.api.v1.business_dashboard import router as business_dashboard_router
from adapters.api.v1.business_users import router as business_users_router
from adapters.api.v1.accounts import router as accounts_router
+from adapters.api.v1.categories import router as categories_router
+from adapters.api.v1.product_attributes import router as product_attributes_router
+from adapters.api.v1.products import router as products_router
+from adapters.api.v1.price_lists import router as price_lists_router
from adapters.api.v1.persons import router as persons_router
+from adapters.api.v1.tax_units import router as tax_units_router
+from adapters.api.v1.tax_types import router as tax_types_router
from adapters.api.v1.support.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_router
from adapters.api.v1.support.categories import router as support_categories_router
@@ -280,7 +286,13 @@ def create_app() -> FastAPI:
application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix)
application.include_router(business_users_router, prefix=settings.api_v1_prefix)
application.include_router(accounts_router, prefix=settings.api_v1_prefix)
+ application.include_router(categories_router, prefix=settings.api_v1_prefix)
+ application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
+ application.include_router(products_router, prefix=settings.api_v1_prefix)
+ application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix)
+ application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
+ application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
# Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
diff --git a/hesabixAPI/app/services/price_list_service.py b/hesabixAPI/app/services/price_list_service.py
new file mode 100644
index 0000000..805d50a
--- /dev/null
+++ b/hesabixAPI/app/services/price_list_service.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+from typing import Dict, Any, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_
+
+from app.core.responses import ApiError
+from adapters.db.repositories.price_list_repository import PriceListRepository, PriceItemRepository
+from adapters.db.models.price_list import PriceList, PriceItem
+from adapters.api.v1.schema_models.price_list import PriceListCreateRequest, PriceListUpdateRequest, PriceItemUpsertRequest
+from adapters.db.models.product import Product
+
+
+def create_price_list(db: Session, business_id: int, payload: PriceListCreateRequest) -> Dict[str, Any]:
+ repo = PriceListRepository(db)
+ # یکتایی نام در هر کسبوکار
+ dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip())).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400)
+ obj = repo.create(
+ business_id=business_id,
+ name=payload.name.strip(),
+ currency_id=payload.currency_id,
+ default_unit_id=payload.default_unit_id,
+ is_active=payload.is_active,
+ )
+ return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)}
+
+
+def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = PriceListRepository(db)
+ take = int(query.get("take", 20) or 20)
+ skip = int(query.get("skip", 0) or 0)
+ sort_by = query.get("sort_by")
+ sort_desc = bool(query.get("sort_desc", True))
+ search = query.get("search")
+ return repo.search(business_id=business_id, take=take, skip=skip, sort_by=sort_by, sort_desc=sort_desc, search=search)
+
+
+def get_price_list(db: Session, business_id: int, id: int) -> Optional[Dict[str, Any]]:
+ obj = db.get(PriceList, id)
+ if not obj or obj.business_id != business_id:
+ return None
+ return _pl_to_dict(obj)
+
+
+def update_price_list(db: Session, business_id: int, id: int, payload: PriceListUpdateRequest) -> Optional[Dict[str, Any]]:
+ repo = PriceListRepository(db)
+ obj = db.get(PriceList, id)
+ if not obj or obj.business_id != business_id:
+ return None
+ if payload.name is not None and payload.name.strip() and payload.name.strip() != obj.name:
+ dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400)
+ updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, currency_id=payload.currency_id, default_unit_id=payload.default_unit_id, is_active=payload.is_active)
+ if not updated:
+ return None
+ return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)}
+
+
+def delete_price_list(db: Session, business_id: int, id: int) -> bool:
+ repo = PriceListRepository(db)
+ obj = db.get(PriceList, id)
+ if not obj or obj.business_id != business_id:
+ return False
+ return repo.delete(id)
+
+
+def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0) -> Dict[str, Any]:
+ # مالکیت را از روی price_list بررسی میکنیم
+ pl = db.get(PriceList, price_list_id)
+ if not pl or pl.business_id != business_id:
+ raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404)
+ repo = PriceItemRepository(db)
+ return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip)
+
+
+def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]:
+ pl = db.get(PriceList, price_list_id)
+ if not pl or pl.business_id != business_id:
+ raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404)
+ # صحت وجود محصول
+ pr = db.get(Product, payload.product_id)
+ if not pr or pr.business_id != business_id:
+ raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404)
+ # اگر unit_id داده شده و با واحدهای محصول سازگار نباشد، خطا بده
+ if payload.unit_id is not None and payload.unit_id not in [pr.main_unit_id, pr.secondary_unit_id]:
+ raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400)
+
+ repo = PriceItemRepository(db)
+ obj = repo.upsert(
+ price_list_id=price_list_id,
+ product_id=payload.product_id,
+ unit_id=payload.unit_id,
+ currency_id=payload.currency_id or pl.currency_id,
+ tier_name=payload.tier_name.strip(),
+ min_qty=payload.min_qty,
+ price=payload.price,
+ )
+ return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)}
+
+
+def delete_price_item(db: Session, business_id: int, id: int) -> bool:
+ repo = PriceItemRepository(db)
+ pi = db.get(PriceItem, id)
+ if not pi:
+ return False
+ # بررسی مالکیت از طریق price_list
+ pl = db.get(PriceList, pi.price_list_id)
+ if not pl or pl.business_id != business_id:
+ return False
+ return repo.delete(id)
+
+
+def _pl_to_dict(obj: PriceList) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "name": obj.name,
+ "currency_id": obj.currency_id,
+ "default_unit_id": obj.default_unit_id,
+ "is_active": obj.is_active,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
+def _pi_to_dict(obj: PriceItem) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "price_list_id": obj.price_list_id,
+ "product_id": obj.product_id,
+ "unit_id": obj.unit_id,
+ "currency_id": obj.currency_id,
+ "tier_name": obj.tier_name,
+ "min_qty": obj.min_qty,
+ "price": obj.price,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
diff --git a/hesabixAPI/app/services/product_attribute_service.py b/hesabixAPI/app/services/product_attribute_service.py
new file mode 100644
index 0000000..06104fd
--- /dev/null
+++ b/hesabixAPI/app/services/product_attribute_service.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import Dict, Any, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, func
+
+from adapters.db.repositories.product_attribute_repository import ProductAttributeRepository
+from adapters.db.models.product_attribute import ProductAttribute
+from adapters.api.v1.schema_models.product_attribute import (
+ ProductAttributeCreateRequest,
+ ProductAttributeUpdateRequest,
+)
+from app.core.responses import ApiError
+
+
+def create_attribute(db: Session, business_id: int, payload: ProductAttributeCreateRequest) -> Dict[str, Any]:
+ repo = ProductAttributeRepository(db)
+ # جلوگیری از عنوان تکراری در هر کسبوکار
+ dup = db.query(ProductAttribute).filter(
+ and_(ProductAttribute.business_id == business_id, func.lower(ProductAttribute.title) == func.lower(payload.title.strip()))
+ ).first()
+ if dup:
+ raise ApiError("DUPLICATE_ATTRIBUTE_TITLE", "عنوان ویژگی تکراری است", http_status=400)
+
+ obj = repo.create(business_id=business_id, title=payload.title.strip(), description=payload.description)
+ return {
+ "message": "ویژگی با موفقیت ایجاد شد",
+ "data": _to_dict(obj),
+ }
+
+
+def list_attributes(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = ProductAttributeRepository(db)
+ take = int(query.get("take", 20) or 20)
+ skip = int(query.get("skip", 0) or 0)
+ sort_by = query.get("sort_by")
+ sort_desc = bool(query.get("sort_desc", True))
+ search = query.get("search")
+ filters = query.get("filters")
+ result = repo.search(
+ business_id=business_id,
+ take=take,
+ skip=skip,
+ sort_by=sort_by,
+ sort_desc=sort_desc,
+ search=search,
+ filters=filters,
+ )
+ return result
+
+
+def get_attribute(db: Session, attribute_id: int, business_id: int) -> Optional[Dict[str, Any]]:
+ obj = db.get(ProductAttribute, attribute_id)
+ if not obj or obj.business_id != business_id:
+ return None
+ return _to_dict(obj)
+
+
+def update_attribute(db: Session, attribute_id: int, business_id: int, payload: ProductAttributeUpdateRequest) -> Optional[Dict[str, Any]]:
+ repo = ProductAttributeRepository(db)
+ # کنترل مالکیت
+ obj = db.get(ProductAttribute, attribute_id)
+ if not obj or obj.business_id != business_id:
+ return None
+ # بررسی تکراری نبودن عنوان
+ if payload.title is not None:
+ title_norm = payload.title.strip()
+ dup = db.query(ProductAttribute).filter(
+ and_(
+ ProductAttribute.business_id == business_id,
+ func.lower(ProductAttribute.title) == func.lower(title_norm),
+ ProductAttribute.id != attribute_id,
+ )
+ ).first()
+ if dup:
+ raise ApiError("DUPLICATE_ATTRIBUTE_TITLE", "عنوان ویژگی تکراری است", http_status=400)
+ updated = repo.update(
+ attribute_id=attribute_id,
+ title=payload.title.strip() if isinstance(payload.title, str) else None,
+ description=payload.description,
+ )
+ if not updated:
+ return None
+ return {
+ "message": "ویژگی با موفقیت ویرایش شد",
+ "data": _to_dict(updated),
+ }
+
+
+def delete_attribute(db: Session, attribute_id: int, business_id: int) -> bool:
+ repo = ProductAttributeRepository(db)
+ obj = db.get(ProductAttribute, attribute_id)
+ if not obj or obj.business_id != business_id:
+ return False
+ return repo.delete(attribute_id=attribute_id)
+
+
+def _to_dict(obj: ProductAttribute) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "title": obj.title,
+ "description": obj.description,
+ "is_active": obj.is_active,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
diff --git a/hesabixAPI/app/services/product_service.py b/hesabixAPI/app/services/product_service.py
new file mode 100644
index 0000000..71f7666
--- /dev/null
+++ b/hesabixAPI/app/services/product_service.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+from typing import Dict, Any, Optional, List
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+from decimal import Decimal
+
+from app.core.responses import ApiError
+from adapters.db.models.product import Product, ProductItemType
+from adapters.db.models.product_attribute import ProductAttribute
+from adapters.db.models.product_attribute_link import ProductAttributeLink
+from adapters.db.repositories.product_repository import ProductRepository
+from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest
+
+
+def _generate_auto_code(db: Session, business_id: int) -> str:
+ codes = [
+ r[0] for r in db.execute(
+ select(Product.code).where(Product.business_id == business_id)
+ ).all()
+ ]
+ max_num = 0
+ for c in codes:
+ if c and c.isdigit():
+ try:
+ max_num = max(max_num, int(c))
+ except ValueError:
+ continue
+ if max_num > 0:
+ return str(max_num + 1)
+ max_id = db.execute(select(func.max(Product.id))).scalar() or 0
+ return f"P{max_id + 1:06d}"
+
+
+def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> None:
+ if getattr(payload, 'is_sales_taxable', False) and getattr(payload, 'sales_tax_rate', None) is None:
+ pass
+ if getattr(payload, 'is_purchase_taxable', False) and getattr(payload, 'purchase_tax_rate', None) is None:
+ pass
+
+
+def _validate_units(main_unit_id: Optional[int], secondary_unit_id: Optional[int], factor: Optional[Decimal]) -> None:
+ if secondary_unit_id and not factor:
+ raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400)
+
+
+def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute_ids: Optional[List[int]]) -> None:
+ if attribute_ids is None:
+ return
+ db.query(ProductAttributeLink).filter(ProductAttributeLink.product_id == product_id).delete()
+ if not attribute_ids:
+ db.commit()
+ return
+ valid_ids = [
+ a.id for a in db.query(ProductAttribute.id, ProductAttribute.business_id)
+ .filter(ProductAttribute.id.in_(attribute_ids), ProductAttribute.business_id == business_id)
+ .all()
+ ]
+ for aid in valid_ids:
+ db.add(ProductAttributeLink(product_id=product_id, attribute_id=aid))
+ db.commit()
+
+
+def create_product(db: Session, business_id: int, payload: ProductCreateRequest) -> Dict[str, Any]:
+ repo = ProductRepository(db)
+ _validate_tax(payload)
+ _validate_units(payload.main_unit_id, payload.secondary_unit_id, payload.unit_conversion_factor)
+
+ code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None
+ if code:
+ dup = db.query(Product).filter(and_(Product.business_id == business_id, Product.code == code)).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
+ else:
+ code = _generate_auto_code(db, business_id)
+
+ obj = repo.create(
+ business_id=business_id,
+ item_type=payload.item_type,
+ code=code,
+ name=payload.name.strip(),
+ description=payload.description,
+ category_id=payload.category_id,
+ main_unit_id=payload.main_unit_id,
+ secondary_unit_id=payload.secondary_unit_id,
+ unit_conversion_factor=payload.unit_conversion_factor,
+ base_sales_price=payload.base_sales_price,
+ base_sales_note=payload.base_sales_note,
+ base_purchase_price=payload.base_purchase_price,
+ base_purchase_note=payload.base_purchase_note,
+ track_inventory=payload.track_inventory,
+ reorder_point=payload.reorder_point,
+ min_order_qty=payload.min_order_qty,
+ lead_time_days=payload.lead_time_days,
+ is_sales_taxable=payload.is_sales_taxable,
+ is_purchase_taxable=payload.is_purchase_taxable,
+ sales_tax_rate=payload.sales_tax_rate,
+ purchase_tax_rate=payload.purchase_tax_rate,
+ tax_type_id=payload.tax_type_id,
+ tax_code=payload.tax_code,
+ tax_unit_id=payload.tax_unit_id,
+ )
+
+ _upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
+
+ return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)}
+
+
+def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = ProductRepository(db)
+ take = int(query.get("take", 20) or 20)
+ skip = int(query.get("skip", 0) or 0)
+ sort_by = query.get("sort_by")
+ sort_desc = bool(query.get("sort_desc", True))
+ search = query.get("search")
+ filters = query.get("filters")
+ return repo.search(
+ business_id=business_id,
+ take=take,
+ skip=skip,
+ sort_by=sort_by,
+ sort_desc=sort_desc,
+ search=search,
+ filters=filters,
+ )
+
+
+def get_product(db: Session, product_id: int, business_id: int) -> Optional[Dict[str, Any]]:
+ obj = db.get(Product, product_id)
+ if not obj or obj.business_id != business_id:
+ return None
+ return _to_dict(obj)
+
+
+def update_product(db: Session, product_id: int, business_id: int, payload: ProductUpdateRequest) -> Optional[Dict[str, Any]]:
+ repo = ProductRepository(db)
+ obj = db.get(Product, product_id)
+ if not obj or obj.business_id != business_id:
+ return None
+
+ if payload.code is not None and payload.code.strip() and payload.code.strip() != obj.code:
+ dup = db.query(Product).filter(and_(Product.business_id == business_id, Product.code == payload.code.strip(), Product.id != product_id)).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
+
+ _validate_tax(payload)
+ _validate_units(payload.main_unit_id if payload.main_unit_id is not None else obj.main_unit_id,
+ payload.secondary_unit_id if payload.secondary_unit_id is not None else obj.secondary_unit_id,
+ payload.unit_conversion_factor if payload.unit_conversion_factor is not None else obj.unit_conversion_factor)
+
+ updated = repo.update(
+ product_id,
+ item_type=payload.item_type,
+ code=payload.code.strip() if isinstance(payload.code, str) else None,
+ name=payload.name.strip() if isinstance(payload.name, str) else None,
+ description=payload.description,
+ category_id=payload.category_id,
+ main_unit_id=payload.main_unit_id,
+ secondary_unit_id=payload.secondary_unit_id,
+ unit_conversion_factor=payload.unit_conversion_factor,
+ base_sales_price=payload.base_sales_price,
+ base_sales_note=payload.base_sales_note,
+ base_purchase_price=payload.base_purchase_price,
+ base_purchase_note=payload.base_purchase_note,
+ track_inventory=payload.track_inventory if payload.track_inventory is not None else None,
+ reorder_point=payload.reorder_point,
+ min_order_qty=payload.min_order_qty,
+ lead_time_days=payload.lead_time_days,
+ is_sales_taxable=payload.is_sales_taxable,
+ is_purchase_taxable=payload.is_purchase_taxable,
+ sales_tax_rate=payload.sales_tax_rate,
+ purchase_tax_rate=payload.purchase_tax_rate,
+ tax_type_id=payload.tax_type_id,
+ tax_code=payload.tax_code,
+ tax_unit_id=payload.tax_unit_id,
+ )
+ if not updated:
+ return None
+
+ _upsert_attributes(db, product_id, business_id, payload.attribute_ids)
+ return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)}
+
+
+def delete_product(db: Session, product_id: int, business_id: int) -> bool:
+ repo = ProductRepository(db)
+ obj = db.get(Product, product_id)
+ if not obj or obj.business_id != business_id:
+ return False
+ return repo.delete(product_id)
+
+
+def _to_dict(obj: Product) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "item_type": obj.item_type.value if hasattr(obj.item_type, 'value') else str(obj.item_type),
+ "code": obj.code,
+ "name": obj.name,
+ "description": obj.description,
+ "category_id": obj.category_id,
+ "main_unit_id": obj.main_unit_id,
+ "secondary_unit_id": obj.secondary_unit_id,
+ "unit_conversion_factor": obj.unit_conversion_factor,
+ "base_sales_price": obj.base_sales_price,
+ "base_sales_note": obj.base_sales_note,
+ "base_purchase_price": obj.base_purchase_price,
+ "base_purchase_note": obj.base_purchase_note,
+ "track_inventory": obj.track_inventory,
+ "reorder_point": obj.reorder_point,
+ "min_order_qty": obj.min_order_qty,
+ "lead_time_days": obj.lead_time_days,
+ "is_sales_taxable": obj.is_sales_taxable,
+ "is_purchase_taxable": obj.is_purchase_taxable,
+ "sales_tax_rate": obj.sales_tax_rate,
+ "purchase_tax_rate": obj.purchase_tax_rate,
+ "tax_type_id": obj.tax_type_id,
+ "tax_code": obj.tax_code,
+ "tax_unit_id": obj.tax_unit_id,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index 9f20a17..d53fd3d 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -8,10 +8,16 @@ adapters/api/v1/auth.py
adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py
adapters/api/v1/businesses.py
+adapters/api/v1/categories.py
adapters/api/v1/currencies.py
adapters/api/v1/health.py
adapters/api/v1/persons.py
+adapters/api/v1/price_lists.py
+adapters/api/v1/product_attributes.py
+adapters/api/v1/products.py
adapters/api/v1/schemas.py
+adapters/api/v1/tax_types.py
+adapters/api/v1/tax_units.py
adapters/api/v1/users.py
adapters/api/v1/admin/email_config.py
adapters/api/v1/admin/file_storage.py
@@ -20,6 +26,9 @@ adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py
+adapters/api/v1/schema_models/price_list.py
+adapters/api/v1/schema_models/product.py
+adapters/api/v1/schema_models/product_attribute.py
adapters/api/v1/support/__init__.py
adapters/api/v1/support/categories.py
adapters/api/v1/support/operator.py
@@ -35,6 +44,7 @@ adapters/db/models/api_key.py
adapters/db/models/business.py
adapters/db/models/business_permission.py
adapters/db/models/captcha.py
+adapters/db/models/category.py
adapters/db/models/currency.py
adapters/db/models/document.py
adapters/db/models/document_line.py
@@ -43,6 +53,11 @@ adapters/db/models/file_storage.py
adapters/db/models/fiscal_year.py
adapters/db/models/password_reset.py
adapters/db/models/person.py
+adapters/db/models/price_list.py
+adapters/db/models/product.py
+adapters/db/models/product_attribute.py
+adapters/db/models/product_attribute_link.py
+adapters/db/models/tax_unit.py
adapters/db/models/user.py
adapters/db/models/support/__init__.py
adapters/db/models/support/category.py
@@ -54,10 +69,14 @@ adapters/db/repositories/api_key_repo.py
adapters/db/repositories/base_repo.py
adapters/db/repositories/business_permission_repo.py
adapters/db/repositories/business_repo.py
+adapters/db/repositories/category_repository.py
adapters/db/repositories/email_config_repository.py
adapters/db/repositories/file_storage_repository.py
adapters/db/repositories/fiscal_year_repo.py
adapters/db/repositories/password_reset_repo.py
+adapters/db/repositories/price_list_repository.py
+adapters/db/repositories/product_attribute_repository.py
+adapters/db/repositories/product_repository.py
adapters/db/repositories/user_repo.py
adapters/db/repositories/support/__init__.py
adapters/db/repositories/support/category_repository.py
@@ -88,6 +107,9 @@ app/services/captcha_service.py
app/services/email_service.py
app/services/file_storage_service.py
app/services/person_service.py
+app/services/price_list_service.py
+app/services/product_attribute_service.py
+app/services/product_service.py
app/services/query_service.py
app/services/pdf/__init__.py
app/services/pdf/base_pdf_service.py
@@ -125,8 +147,15 @@ migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
migrations/versions/20250927_000022_add_person_commission_fields.py
migrations/versions/20250928_000023_remove_person_is_active_force.py
+migrations/versions/20250929_000101_add_categories_table.py
+migrations/versions/20250929_000201_drop_type_from_categories.py
+migrations/versions/20250929_000301_add_product_attributes_table.py
+migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py
+migrations/versions/20250929_000501_add_products_and_pricing.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
+migrations/versions/9f9786ae7191_create_tax_units_table.py
+migrations/versions/caf3f4ef4b76_add_tax_units_table.py
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
migrations/versions/f876bfa36805_merge_multiple_heads.py
tests/__init__.py
diff --git a/hesabixAPI/migrations/versions/20250916_000002_add_referral_fields.py b/hesabixAPI/migrations/versions/20250916_000002_add_referral_fields.py
index 4f5bc37..51f5b5b 100644
--- a/hesabixAPI/migrations/versions/20250916_000002_add_referral_fields.py
+++ b/hesabixAPI/migrations/versions/20250916_000002_add_referral_fields.py
@@ -23,7 +23,7 @@ def upgrade() -> None:
# Fetch all user ids
res = bind.execute(sa.text("SELECT id FROM users"))
- user_ids = [row[0] for row in res]
+ user_ids = [row[0] for row in res] if res else []
# Helper to generate unique codes
import secrets
diff --git a/hesabixAPI/migrations/versions/20250929_000101_add_categories_table.py b/hesabixAPI/migrations/versions/20250929_000101_add_categories_table.py
new file mode 100644
index 0000000..c033524
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250929_000101_add_categories_table.py
@@ -0,0 +1,36 @@
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000101_add_categories_table'
+down_revision = '20250928_000023_remove_person_is_active_force'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if 'categories' in inspector.get_table_names():
+ return
+
+ op.create_table(
+ 'categories',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False, index=True),
+ sa.Column('parent_id', sa.Integer(), sa.ForeignKey('categories.id', ondelete='SET NULL'), nullable=True, index=True),
+ sa.Column('type', sa.String(length=16), nullable=False, index=True),
+ sa.Column('title_translations', sa.JSON(), nullable=False),
+ sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ )
+ # Indexes are created automatically if defined at ORM/model level or can be added in a later migration if needed
+
+
+def downgrade() -> None:
+ op.drop_table('categories')
+
+
diff --git a/hesabixAPI/migrations/versions/20250929_000201_drop_type_from_categories.py b/hesabixAPI/migrations/versions/20250929_000201_drop_type_from_categories.py
new file mode 100644
index 0000000..17593c5
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250929_000201_drop_type_from_categories.py
@@ -0,0 +1,41 @@
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000201_drop_type_from_categories'
+down_revision = '20250929_000101_add_categories_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # حذف ایندکس مرتبط با ستون type اگر وجود دارد
+ try:
+ op.drop_index('ix_categories_type', table_name='categories')
+ except Exception:
+ pass
+ # حذف ستون type
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ cols = [c['name'] for c in inspector.get_columns('categories')]
+ if 'type' in cols:
+ with op.batch_alter_table('categories') as batch_op:
+ try:
+ batch_op.drop_column('type')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ # بازگردانی ستون type (اختیاری)
+ with op.batch_alter_table('categories') as batch_op:
+ try:
+ batch_op.add_column(sa.Column('type', sa.String(length=16), nullable=False, server_default='global'))
+ except Exception:
+ pass
+ try:
+ op.create_index('ix_categories_type', 'categories', ['type'])
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/migrations/versions/20250929_000301_add_product_attributes_table.py b/hesabixAPI/migrations/versions/20250929_000301_add_product_attributes_table.py
new file mode 100644
index 0000000..5e514ed
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250929_000301_add_product_attributes_table.py
@@ -0,0 +1,34 @@
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000301_add_product_attributes_table'
+down_revision = '20250929_000201_drop_type_from_categories'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if 'product_attributes' in inspector.get_table_names():
+ return
+
+ op.create_table(
+ 'product_attributes',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False, index=True),
+ sa.Column('title', sa.String(length=255), nullable=False, index=True),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ sa.UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table('product_attributes')
+
+
diff --git a/hesabixAPI/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py b/hesabixAPI/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py
new file mode 100644
index 0000000..1ed252c
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py
@@ -0,0 +1,31 @@
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000401_drop_is_active_from_product_attributes'
+down_revision = '20250929_000301_add_product_attributes_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ cols = [c['name'] for c in inspector.get_columns('product_attributes')]
+ if 'is_active' in cols:
+ with op.batch_alter_table('product_attributes') as batch_op:
+ try:
+ batch_op.drop_column('is_active')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('product_attributes') as batch_op:
+ try:
+ batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/migrations/versions/20250929_000501_add_products_and_pricing.py b/hesabixAPI/migrations/versions/20250929_000501_add_products_and_pricing.py
new file mode 100644
index 0000000..7295181
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250929_000501_add_products_and_pricing.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000501_add_products_and_pricing'
+down_revision = '20250929_000401_drop_is_active_from_product_attributes'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create products table (with existence check)
+ connection = op.get_bind()
+
+ # Check if products table exists
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'products',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('item_type', sa.Enum('کالا', 'خدمت', name='product_item_type_enum'), nullable=False),
+ sa.Column('code', sa.String(length=64), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('category_id', sa.Integer(), nullable=True),
+ sa.Column('main_unit_id', sa.Integer(), nullable=True),
+ sa.Column('secondary_unit_id', sa.Integer(), nullable=True),
+ sa.Column('unit_conversion_factor', sa.Numeric(18, 6), nullable=True),
+ sa.Column('base_sales_price', sa.Numeric(18, 2), nullable=True),
+ sa.Column('base_sales_note', sa.Text(), nullable=True),
+ sa.Column('base_purchase_price', sa.Numeric(18, 2), nullable=True),
+ sa.Column('base_purchase_note', sa.Text(), nullable=True),
+ sa.Column('track_inventory', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('reorder_point', sa.Integer(), nullable=True),
+ sa.Column('min_order_qty', sa.Integer(), nullable=True),
+ sa.Column('lead_time_days', sa.Integer(), nullable=True),
+ sa.Column('is_sales_taxable', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('is_purchase_taxable', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('sales_tax_rate', sa.Numeric(5, 2), nullable=True),
+ sa.Column('purchase_tax_rate', sa.Numeric(5, 2), nullable=True),
+ sa.Column('tax_type_id', sa.Integer(), nullable=True),
+ sa.Column('tax_code', sa.String(length=100), nullable=True),
+ sa.Column('tax_unit_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Create constraints and indexes (with existence checks)
+ try:
+ op.create_unique_constraint('uq_products_business_code', 'products', ['business_id', 'code'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_products_business_id', 'products', ['business_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_index('ix_products_name', 'products', ['name'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'products', 'businesses', ['business_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'products', 'categories', ['category_id'], ['id'], ondelete='SET NULL')
+ except Exception:
+ pass # Foreign key already exists
+
+ # Create price_lists table (with existence check)
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'price_lists'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'price_lists',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('currency_id', sa.Integer(), nullable=True),
+ sa.Column('default_unit_id', sa.Integer(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ try:
+ op.create_unique_constraint('uq_price_lists_business_name', 'price_lists', ['business_id', 'name'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_price_lists_business_id', 'price_lists', ['business_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'price_lists', 'businesses', ['business_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT')
+ except Exception:
+ pass # Foreign key already exists
+
+ # Create price_items table (with existence check)
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'price_items'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'price_items',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('price_list_id', sa.Integer(), nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('unit_id', sa.Integer(), nullable=True),
+ sa.Column('currency_id', sa.Integer(), nullable=True),
+ sa.Column('tier_name', sa.String(length=64), nullable=False),
+ sa.Column('min_qty', sa.Numeric(18, 3), nullable=False, server_default=sa.text('0')),
+ sa.Column('price', sa.Numeric(18, 2), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ try:
+ op.create_unique_constraint('uq_price_items_unique_tier', 'price_items', ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_price_items_price_list_id', 'price_items', ['price_list_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_index('ix_price_items_product_id', 'price_items', ['product_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'price_items', 'price_lists', ['price_list_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'price_items', 'products', ['product_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'price_items', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT')
+ except Exception:
+ pass # Foreign key already exists
+
+ # Create product_attribute_links table (with existence check)
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'product_attribute_links'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'product_attribute_links',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('attribute_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ try:
+ op.create_unique_constraint('uq_product_attribute_links_unique', 'product_attribute_links', ['product_id', 'attribute_id'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_product_attribute_links_product_id', 'product_attribute_links', ['product_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_index('ix_product_attribute_links_attribute_id', 'product_attribute_links', ['attribute_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'product_attribute_links', 'products', ['product_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'product_attribute_links', 'product_attributes', ['attribute_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+
+def downgrade() -> None:
+ # Drop links and pricing first due to FKs
+ op.drop_constraint('uq_product_attribute_links_unique', 'product_attribute_links', type_='unique')
+ op.drop_table('product_attribute_links')
+
+ op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique')
+ op.drop_table('price_items')
+
+ op.drop_constraint('uq_price_lists_business_name', 'price_lists', type_='unique')
+ op.drop_table('price_lists')
+
+ op.drop_constraint('uq_products_business_code', 'products', type_='unique')
+ op.drop_table('products')
+
+
diff --git a/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py b/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py
new file mode 100644
index 0000000..2079041
--- /dev/null
+++ b/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py
@@ -0,0 +1,50 @@
+"""create_tax_units_table
+
+Revision ID: 9f9786ae7191
+Revises: caf3f4ef4b76
+Create Date: 2025-09-30 14:47:28.281817
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9f9786ae7191'
+down_revision = 'caf3f4ef4b76'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create tax_units table
+ op.create_table('tax_units',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
+ sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
+ sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
+ sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
+ sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Create indexes
+ op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
+
+ # Add foreign key constraint to products table
+ op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
+
+
+def downgrade() -> None:
+ # Drop foreign key constraint from products table
+ op.drop_constraint(None, 'products', type_='foreignkey')
+
+ # Drop indexes
+ op.drop_index(op.f('ix_tax_units_business_id'), table_name='tax_units')
+
+ # Drop tax_units table
+ op.drop_table('tax_units')
diff --git a/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py b/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py
new file mode 100644
index 0000000..e27ba39
--- /dev/null
+++ b/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py
@@ -0,0 +1,169 @@
+"""add_tax_units_table
+
+Revision ID: caf3f4ef4b76
+Revises: 20250929_000501_add_products_and_pricing
+Create Date: 2025-09-30 14:46:58.614162
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = 'caf3f4ef4b76'
+down_revision = '20250929_000501_add_products_and_pricing'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_type',
+ existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
+ comment='نوع شخص',
+ existing_nullable=False)
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sale_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment='درصد پورسانت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_return_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment='درصد پورسانت از برگشت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment='مبلغ فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_return_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment='مبلغ برگشت از فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_exclude_discounts',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment='عدم محاسبه تخفیف در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_exclude_additions_deductions',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_post_in_invoice_document',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment='ثبت پورسانت در سند حسابداری فاکتور',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('price_items', 'tier_name',
+ existing_type=mysql.VARCHAR(length=64),
+ comment='نام پله قیمت (تکی/عمده/همکار/...)',
+ existing_nullable=False)
+ op.create_index(op.f('ix_price_items_currency_id'), 'price_items', ['currency_id'], unique=False)
+ op.create_index(op.f('ix_price_items_unit_id'), 'price_items', ['unit_id'], unique=False)
+ op.create_index(op.f('ix_price_lists_currency_id'), 'price_lists', ['currency_id'], unique=False)
+ op.create_index(op.f('ix_price_lists_default_unit_id'), 'price_lists', ['default_unit_id'], unique=False)
+ op.create_index(op.f('ix_price_lists_name'), 'price_lists', ['name'], unique=False)
+ op.alter_column('products', 'item_type',
+ existing_type=mysql.ENUM('کالا', 'خدمت'),
+ comment='نوع آیتم (کالا/خدمت)',
+ existing_nullable=False)
+ op.alter_column('products', 'code',
+ existing_type=mysql.VARCHAR(length=64),
+ comment='کد یکتا در هر کسب\u200cوکار',
+ existing_nullable=False)
+ op.create_index(op.f('ix_products_category_id'), 'products', ['category_id'], unique=False)
+ op.create_index(op.f('ix_products_main_unit_id'), 'products', ['main_unit_id'], unique=False)
+ op.create_index(op.f('ix_products_secondary_unit_id'), 'products', ['secondary_unit_id'], unique=False)
+ op.create_index(op.f('ix_products_tax_type_id'), 'products', ['tax_type_id'], unique=False)
+ op.create_index(op.f('ix_products_tax_unit_id'), 'products', ['tax_unit_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_products_tax_unit_id'), table_name='products')
+ op.drop_index(op.f('ix_products_tax_type_id'), table_name='products')
+ op.drop_index(op.f('ix_products_secondary_unit_id'), table_name='products')
+ op.drop_index(op.f('ix_products_main_unit_id'), table_name='products')
+ op.drop_index(op.f('ix_products_category_id'), table_name='products')
+ op.alter_column('products', 'code',
+ existing_type=mysql.VARCHAR(length=64),
+ comment=None,
+ existing_comment='کد یکتا در هر کسب\u200cوکار',
+ existing_nullable=False)
+ op.alter_column('products', 'item_type',
+ existing_type=mysql.ENUM('کالا', 'خدمت'),
+ comment=None,
+ existing_comment='نوع آیتم (کالا/خدمت)',
+ existing_nullable=False)
+ op.drop_index(op.f('ix_price_lists_name'), table_name='price_lists')
+ op.drop_index(op.f('ix_price_lists_default_unit_id'), table_name='price_lists')
+ op.drop_index(op.f('ix_price_lists_currency_id'), table_name='price_lists')
+ op.drop_index(op.f('ix_price_items_unit_id'), table_name='price_items')
+ op.drop_index(op.f('ix_price_items_currency_id'), table_name='price_items')
+ op.alter_column('price_items', 'tier_name',
+ existing_type=mysql.VARCHAR(length=64),
+ comment=None,
+ existing_comment='نام پله قیمت (تکی/عمده/همکار/...)',
+ existing_nullable=False)
+ op.alter_column('persons', 'commission_post_in_invoice_document',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment=None,
+ existing_comment='ثبت پورسانت در سند حسابداری فاکتور',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_exclude_additions_deductions',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment=None,
+ existing_comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_exclude_discounts',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment=None,
+ existing_comment='عدم محاسبه تخفیف در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_sales_return_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment=None,
+ existing_comment='مبلغ برگشت از فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment=None,
+ existing_comment='مبلغ فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_return_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment=None,
+ existing_comment='درصد پورسانت از برگشت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sale_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment=None,
+ existing_comment='درصد پورسانت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment=None,
+ existing_comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_type',
+ existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
+ comment=None,
+ existing_comment='نوع شخص',
+ existing_nullable=False)
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ # ### end Alembic commands ###
diff --git a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart
new file mode 100644
index 0000000..263e9f9
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart
@@ -0,0 +1,280 @@
+import 'package:flutter/material.dart';
+import '../models/product_form_data.dart';
+import '../services/product_service.dart';
+import '../services/category_service.dart';
+import '../services/product_attribute_service.dart';
+import '../services/unit_service.dart';
+import '../services/tax_service.dart';
+import '../core/api_client.dart';
+
+class ProductFormController extends ChangeNotifier {
+ final int businessId;
+ final ApiClient _apiClient;
+
+ late final ProductService _productService;
+ late final CategoryService _categoryService;
+ late final ProductAttributeService _attributeService;
+ late final UnitService _unitService;
+ late final TaxService _taxService;
+
+ ProductFormData _formData = ProductFormData();
+ bool _isLoading = false;
+ String? _errorMessage;
+ int? _editingProductId;
+
+ // Reference data
+ List