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> _categories = []; + List> _attributes = []; + List> _units = []; + List> _taxTypes = []; + List> _taxUnits = []; + + ProductFormController({ + required this.businessId, + ApiClient? apiClient, + }) : _apiClient = apiClient ?? ApiClient() { + _initializeServices(); + } + + void _initializeServices() { + _productService = ProductService(apiClient: _apiClient); + _categoryService = CategoryService(_apiClient); + _attributeService = ProductAttributeService(apiClient: _apiClient); + _unitService = UnitService(apiClient: _apiClient); + _taxService = TaxService(apiClient: _apiClient); + } + + // Getters + ProductFormData get formData => _formData; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + List> get categories => _categories; + List> get attributes => _attributes; + List> get units => _units; + List> get taxTypes => _taxTypes; + List> get taxUnits => _taxUnits; + + // Initialize form with existing product data + Future initializeWithProduct(Map? product) async { + _setLoading(true); + try { + await _loadReferenceData(); + + if (product != null) { + _editingProductId = product['id'] as int?; + _formData = ProductFormData.fromProduct(product); + } else { + _formData = ProductFormData( + baseSalesPrice: 0, + basePurchasePrice: 0, + ); + // پیش‌فرض انتخاب اولین نوع مالیات و واحد مالیاتی اگر موجود باشد + if (_taxTypes.isNotEmpty && _formData.taxTypeId == null) { + final firstTaxTypeId = (_taxTypes.first['id'] as num?)?.toInt(); + if (firstTaxTypeId != null) { + _formData = _formData.copyWith(taxTypeId: firstTaxTypeId); + } + } + if (_taxUnits.isNotEmpty && _formData.taxUnitId == null) { + final firstTaxUnitId = (_taxUnits.first['id'] as num?)?.toInt(); + if (firstTaxUnitId != null) { + _formData = _formData.copyWith(taxUnitId: firstTaxUnitId); + } + } + } + // Default main unit id: prefer unit titled "عدد", then first available, else 1 + if (_formData.mainUnitId == null) { + int? unitId; + try { + final numberUnit = _units.firstWhere( + (e) => ((e['title'] ?? e['name'])?.toString().trim() ?? '') == 'عدد', + ); + unitId = (numberUnit['id'] as num?)?.toInt(); + } catch (_) { + // ignore + } + unitId ??= _units.isNotEmpty ? (_units.first['id'] as num).toInt() : 1; + _formData = _formData.copyWith(mainUnitId: unitId); + } + + _clearError(); + notifyListeners(); + } catch (e) { + _setError('خطا در بارگذاری اطلاعات: $e'); + } finally { + _setLoading(false); + } + } + + // Load all reference data + Future _loadReferenceData() async { + try { + // Load categories + _categories = await _categoryService.getTree(businessId: businessId); + + // Load attributes + try { + final attrsRes = await _attributeService.search(businessId: businessId, limit: 100); + final items = List>.from(attrsRes['items'] ?? const []); + _attributes = items; + } catch (_) { + _attributes = []; + } + + // Load units + try { + _units = await _unitService.getUnits(businessId: businessId); + } catch (_) { + _units = []; + } + + // Load tax types + try { + _taxTypes = await _taxService.getTaxTypes(businessId: businessId); + } catch (_) { + _taxTypes = []; + } + + // Load tax units + try { + _taxUnits = await _taxService.getTaxUnits(businessId: businessId); + } catch (_) { + _taxUnits = []; + } + } catch (e) { + throw Exception('خطا در بارگذاری اطلاعات مرجع: $e'); + } + } + + // Update form data + void updateFormData(ProductFormData newData) { + _formData = newData; + _clearError(); + notifyListeners(); + } + + // Validate form + bool validateForm(GlobalKey formKey) { + return formKey.currentState?.validate() ?? false; + } + + // Submit form + Future submitForm() async { + if (!_formData.name.trim().isNotEmpty) { + _setError('نام کالا الزامی است'); + return false; + } + + _setLoading(true); + try { + final payload = _formData.toPayload(); + // Check duplicate code if provided + final trimmedCode = _formData.code?.trim(); + if (trimmedCode != null && trimmedCode.isNotEmpty) { + final exists = await _productService.codeExists( + businessId: businessId, + code: trimmedCode, + excludeProductId: _editingProductId, + ); + if (exists) { + _setError('کد کالا/خدمت تکراری است. لطفاً کد دیگری انتخاب کنید.'); + return false; + } + } + + // Check if this is an update or create + final isUpdate = _formData.code != null; // Assuming code indicates existing product + + if (isUpdate) { + // For update, we need the product ID - this should be passed from the calling widget + throw UnimplementedError('Update functionality needs product ID'); + } else { + await _productService.createProduct(businessId: businessId, payload: payload); + } + + _clearError(); + return true; + } catch (e) { + _setError('خطا در ذخیره اطلاعات: $e'); + return false; + } finally { + _setLoading(false); + } + } + + // Update existing product + Future updateProduct(int productId) async { + if (!_formData.name.trim().isNotEmpty) { + _setError('نام کالا الزامی است'); + return false; + } + + _setLoading(true); + try { + final payload = _formData.toPayload(); + // Pre-check duplicate code before sending + final trimmedCode = _formData.code?.trim(); + if (trimmedCode != null && trimmedCode.isNotEmpty) { + final exists = await _productService.codeExists( + businessId: businessId, + code: trimmedCode, + excludeProductId: productId, + ); + if (exists) { + _setError('کد کالا/خدمت تکراری است. لطفاً کد دیگری انتخاب کنید.'); + return false; + } + } + await _productService.updateProduct( + businessId: businessId, + productId: productId, + payload: payload, + ); + + _clearError(); + return true; + } catch (e) { + _setError('خطا در به‌روزرسانی اطلاعات: $e'); + return false; + } finally { + _setLoading(false); + } + } + + // Reset form + void resetForm() { + _formData = ProductFormData(); + _clearError(); + notifyListeners(); + } + + // Save form data to local storage (for auto-save) + void saveToLocalStorage() { + // Implementation for local storage persistence + // This could use SharedPreferences or similar + } + + // Load form data from local storage + void loadFromLocalStorage() { + // Implementation for loading from local storage + } + + void _setLoading(bool loading) { + _isLoading = loading; + notifyListeners(); + } + + void _setError(String error) { + _errorMessage = error; + notifyListeners(); + } + + void _clearError() { + _errorMessage = null; + } + + @override + void dispose() { + super.dispose(); + } +} diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index f8eb32f..1a78f46 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -100,6 +100,11 @@ class ApiClient { if (resolvedBusinessId != null) { options.headers['X-Business-ID'] = resolvedBusinessId.toString(); } + // Inject X-Currency header from authStore selection (code preferred) + final currencyCode = _authStore?.selectedCurrencyCode; + if (currencyCode != null && currencyCode.isNotEmpty) { + options.headers['X-Currency'] = currencyCode; + } } catch (_) { // ignore header injection failures } diff --git a/hesabixUI/hesabix_ui/lib/core/auth_store.dart b/hesabixUI/hesabix_ui/lib/core/auth_store.dart index 1f573a4..f6e436b 100644 --- a/hesabixUI/hesabix_ui/lib/core/auth_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/auth_store.dart @@ -13,6 +13,8 @@ class AuthStore with ChangeNotifier { static const _kIsSuperAdmin = 'is_superadmin'; static const _kLastUrl = 'last_url'; static const _kCurrentBusiness = 'current_business'; + static const _kSelectedCurrencyCode = 'selected_currency_code'; + static const _kSelectedCurrencyId = 'selected_currency_id'; final FlutterSecureStorage _secure = const FlutterSecureStorage(); String? _apiKey; @@ -21,6 +23,8 @@ class AuthStore with ChangeNotifier { bool _isSuperAdmin = false; BusinessWithPermission? _currentBusiness; Map? _businessPermissions; + String? _selectedCurrencyCode; // مثل USD/EUR/IRR + int? _selectedCurrencyId; // شناسه ارز در دیتابیس String? get apiKey => _apiKey; String get deviceId => _deviceId ?? ''; @@ -30,6 +34,8 @@ class AuthStore with ChangeNotifier { int? get currentUserId => _currentUserId; BusinessWithPermission? get currentBusiness => _currentBusiness; Map? get businessPermissions => _businessPermissions; + String? get selectedCurrencyCode => _selectedCurrencyCode; + int? get selectedCurrencyId => _selectedCurrencyId; Future load() async { final prefs = await SharedPreferences.getInstance(); @@ -48,6 +54,8 @@ class AuthStore with ChangeNotifier { // بارگذاری دسترسی‌های اپلیکیشن await _loadAppPermissions(); + // بارگذاری ارز انتخاب‌شده (در سطح اپ/کسب‌وکار) + await _loadSelectedCurrency(); // اگر API key موجود است اما دسترسی‌ها نیست، از سرور دریافت کن if (_apiKey != null && _apiKey!.isNotEmpty && (_appPermissions == null || _appPermissions!.isEmpty)) { @@ -249,6 +257,9 @@ class AuthStore with ChangeNotifier { // ذخیره در حافظه محلی await _saveCurrentBusiness(); + + // اگر ارز انتخاب نشده یا ارز انتخابی با کسب‌وکار ناسازگار است، ارز پیشفرض کسب‌وکار را ست کن + await _ensureCurrencyForBusiness(); } Future clearCurrentBusiness() async { @@ -278,6 +289,22 @@ class AuthStore with ChangeNotifier { 'is_owner': _currentBusiness!.isOwner, 'role': _currentBusiness!.role, 'permissions': _currentBusiness!.permissions, + 'default_currency': _currentBusiness!.defaultCurrency != null + ? { + 'id': _currentBusiness!.defaultCurrency!.id, + 'code': _currentBusiness!.defaultCurrency!.code, + 'title': _currentBusiness!.defaultCurrency!.title, + 'symbol': _currentBusiness!.defaultCurrency!.symbol, + } + : null, + 'currencies': _currentBusiness!.currencies + .map((c) => { + 'id': c.id, + 'code': c.code, + 'title': c.title, + 'symbol': c.symbol, + }) + .toList(), }); if (kIsWeb) { @@ -382,6 +409,64 @@ class AuthStore with ChangeNotifier { return sectionPerms.keys.where((key) => sectionPerms[key] == true).toList(); } + + // مدیریت ارز انتخاب‌شده + Future _loadSelectedCurrency() async { + final prefs = await SharedPreferences.getInstance(); + final code = prefs.getString(_kSelectedCurrencyCode); + final id = prefs.getInt(_kSelectedCurrencyId); + _selectedCurrencyCode = code; + _selectedCurrencyId = id; + } + + Future setSelectedCurrency({required String code, int? id}) async { + final prefs = await SharedPreferences.getInstance(); + _selectedCurrencyCode = code; + _selectedCurrencyId = id; + await prefs.setString(_kSelectedCurrencyCode, code); + if (id != null) { + await prefs.setInt(_kSelectedCurrencyId, id); + } else { + await prefs.remove(_kSelectedCurrencyId); + } + notifyListeners(); + } + + Future clearSelectedCurrency() async { + final prefs = await SharedPreferences.getInstance(); + _selectedCurrencyCode = null; + _selectedCurrencyId = null; + await prefs.remove(_kSelectedCurrencyCode); + await prefs.remove(_kSelectedCurrencyId); + notifyListeners(); + } + + Future _ensureCurrencyForBusiness() async { + final business = _currentBusiness; + if (business == null) return; + // اگر ارزی انتخاب نشده، یا کد/شناسه فعلی جزو ارزهای کسب‌وکار نیست + final allowedCodes = business.currencies.map((c) => c.code).toSet(); + final allowedIds = business.currencies.map((c) => c.id).toSet(); + + final hasValidCode = _selectedCurrencyCode != null && allowedCodes.contains(_selectedCurrencyCode); + final hasValidId = _selectedCurrencyId != null && allowedIds.contains(_selectedCurrencyId); + + if (hasValidCode || hasValidId) { + return; // همان را نگه داریم + } + + // در غیر اینصورت ارز پیشفرض کسب‌وکار را ست کن اگر موجود است + if (business.defaultCurrency != null) { + await setSelectedCurrency(code: business.defaultCurrency!.code, id: business.defaultCurrency!.id); + return; + } + + // یا اگر لیست ارزها خالی نیست، اولین ارز را ست کن + if (business.currencies.isNotEmpty) { + final c = business.currencies.first; + await setSelectedCurrency(code: c.code, id: c.id); + } + } } diff --git a/hesabixUI/hesabix_ui/lib/examples/product_management_example.dart b/hesabixUI/hesabix_ui/lib/examples/product_management_example.dart new file mode 100644 index 0000000..d9a8d4a --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/examples/product_management_example.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../core/auth_store.dart'; +import '../widgets/product/product_form_dialog.dart'; + +class ProductManagementExample extends StatelessWidget { + final int businessId; + final AuthStore authStore; + + const ProductManagementExample({ + super.key, + required this.businessId, + required this.authStore, + }); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('مدیریت کالاها'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showAddProductDialog(context), + tooltip: t.addProduct, + ), + ], + ), + body: ListView( + children: [ + // Example product list items + _buildProductListItem( + context, + id: 1, + name: 'کالای نمونه ۱', + code: 'P001', + price: 100000, + onEdit: () => _showEditProductDialog(context, 1), + ), + _buildProductListItem( + context, + id: 2, + name: 'کالای نمونه ۲', + code: 'P002', + price: 250000, + onEdit: () => _showEditProductDialog(context, 2), + ), + ], + ), + ); + } + + Widget _buildProductListItem( + BuildContext context, { + required int id, + required String name, + required String code, + required int price, + required VoidCallback onEdit, + }) { + return Card( + margin: const EdgeInsets.all(8), + child: ListTile( + leading: const CircleAvatar( + child: Icon(Icons.inventory), + ), + title: Text(name), + subtitle: Text('کد: $code - قیمت: ${price.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + )} تومان'), + trailing: PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'edit', + child: const Row( + children: [ + Icon(Icons.edit), + SizedBox(width: 8), + Text('ویرایش'), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: const Row( + children: [ + Icon(Icons.delete, color: Colors.red), + SizedBox(width: 8), + Text('حذف', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'edit') { + onEdit(); + } else if (value == 'delete') { + _showDeleteConfirmation(context, id, name); + } + }, + ), + onTap: onEdit, + ), + ); + } + + void _showAddProductDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ProductFormDialog( + businessId: businessId, + authStore: authStore, + onSuccess: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('کالا با موفقیت اضافه شد'), + backgroundColor: Colors.green, + ), + ); + }, + ), + ); + } + + void _showEditProductDialog(BuildContext context, int productId) { + // In a real app, you would fetch the product data from your service + final productData = { + 'id': productId, + 'name': 'کالای نمونه $productId', + 'code': 'P00$productId', + 'item_type': 'کالا', + 'description': 'توضیحات کالای نمونه $productId', + 'base_sales_price': productId == 1 ? 100000 : 250000, + 'track_inventory': true, + 'is_sales_taxable': false, + 'is_purchase_taxable': false, + }; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ProductFormDialog( + businessId: businessId, + authStore: authStore, + product: productData, + onSuccess: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('کالا با موفقیت به‌روزرسانی شد'), + backgroundColor: Colors.green, + ), + ); + }, + ), + ); + } + + void _showDeleteConfirmation(BuildContext context, int productId, String productName) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('تأیید حذف'), + content: Text('آیا از حذف "$productName" اطمینان دارید؟'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + // In a real app, you would call your delete service here + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('کالا با موفقیت حذف شد'), + backgroundColor: Colors.orange, + ), + ); + }, + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('حذف'), + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 0d463e3..1e45f9e 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -500,6 +500,21 @@ "priceLists": "Price Lists", "categories": "Categories", "productAttributes": "Product Attributes", + "addAttribute": "Add Attribute", + "viewAttributes": "View Attributes", + "editAttributes": "Edit Attributes", + "deleteAttributes": "Delete Attributes", + "title": "Title", + "description": "Description", + "actions": "Actions", + "createdAt": "Created At", + "active": "Active", + "add": "Add", + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "deleteConfirm": "Are you sure to delete \"{name}\"?", "banking": "Banking", "accounts": "Accounts", "pettyCash": "Petty Cash", @@ -679,6 +694,17 @@ "viewAttributes": "View Attributes", "editAttributes": "Edit Attributes", "deleteAttributes": "Delete Attributes", + "title": "Title", + "description": "Description", + "actions": "Actions", + "createdAt": "Created At", + "active": "Active", + "add": "Add", + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "deleteConfirm": "Are you sure to delete \"{name}\"?", "addBankAccount": "Add Bank Account", "viewBankAccounts": "View Bank Accounts", "editBankAccounts": "Edit Bank Accounts", @@ -914,5 +940,23 @@ "commissionExcludeDiscounts": "Exclude discounts from commission", "commissionExcludeAdditionsDeductions": "Exclude additions/deductions from commission", "commissionPostInInvoiceDocument": "Post commission in invoice accounting document" + , + "manageCategories": "Manage Categories", + "categoriesDialogTitle": "Manage Categories", + "addRootCategory": "Add Root", + "addChildCategory": "Add Child", + "renameCategory": "Rename", + "deleteCategory": "Delete Category", + "deleteCategoryConfirm": "Are you sure you want to delete this category?", + "categoryNameFa": "Name (Persian)", + "categoryNameEn": "Name (English)", + "categoryType": "Type", + "productType": "Product", + "serviceType": "Service", + "loadingCategories": "Loading categories...", + "createCategory": "Create Category", + "updateCategory": "Update Category", + "deleteCategorySuccess": "Category deleted", + "operationFailed": "Operation failed" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 0f32852..1f5eecf 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -674,6 +674,17 @@ "warehouses": "مدیریت انبارها", "warehouseTransfers": "صدور حواله", "productAttributes": "ویژگی‌های کالا و خدمات", + "title": "عنوان", + "description": "توضیحات", + "actions": "عملیات", + "createdAt": "تاریخ ایجاد", + "active": "فعال", + "add": "افزودن", + "edit": "ویرایش", + "save": "ذخیره", + "cancel": "لغو", + "delete": "حذف", + "deleteConfirm": "آیا از حذف \"{name}\" مطمئن هستید؟", "addAttribute": "افزودن ویژگی", "viewAttributes": "مشاهده ویژگی‌ها", "editAttributes": "ویرایش ویژگی‌ها", @@ -913,5 +924,23 @@ "commissionExcludeDiscounts": "عدم محاسبه تخفیف", "commissionExcludeAdditionsDeductions": "عدم محاسبه اضافات و کسورات فاکتور", "commissionPostInInvoiceDocument": "ثبت پورسانت در سند حسابداری فاکتور" + , + "manageCategories": "مدیریت دسته‌بندی‌ها", + "categoriesDialogTitle": "مدیریت دسته‌بندی‌ها", + "addRootCategory": "افزودن ریشه", + "addChildCategory": "افزودن زیرشاخه", + "renameCategory": "تغییر نام", + "deleteCategory": "حذف دسته‌بندی", + "deleteCategoryConfirm": "آیا از حذف این دسته‌بندی مطمئن هستید؟", + "categoryNameFa": "نام (فارسی)", + "categoryNameEn": "نام (انگلیسی)", + "categoryType": "نوع", + "productType": "کالا", + "serviceType": "خدمت", + "loadingCategories": "در حال بارگذاری دسته‌بندی‌ها...", + "createCategory": "ایجاد دسته‌بندی", + "updateCategory": "به‌روزرسانی دسته‌بندی", + "deleteCategorySuccess": "دسته‌بندی حذف شد", + "operationFailed": "عملیات ناموفق بود" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index efc0c7c..7a0a1ba 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -2339,8 +2339,8 @@ abstract class AppLocalizations { /// No description provided for @deleteConfirm. /// /// In en, this message translates to: - /// **'Confirm Delete'** - String get deleteConfirm; + /// **'Are you sure to delete \"{name}\"?'** + String deleteConfirm(Object name); /// No description provided for @deleteConfirmMessage. /// @@ -2810,6 +2810,48 @@ abstract class AppLocalizations { /// **'Product Attributes'** String get productAttributes; + /// No description provided for @addAttribute. + /// + /// In en, this message translates to: + /// **'Add Attribute'** + String get addAttribute; + + /// No description provided for @viewAttributes. + /// + /// In en, this message translates to: + /// **'View Attributes'** + String get viewAttributes; + + /// No description provided for @editAttributes. + /// + /// In en, this message translates to: + /// **'Edit Attributes'** + String get editAttributes; + + /// No description provided for @deleteAttributes. + /// + /// In en, this message translates to: + /// **'Delete Attributes'** + String get deleteAttributes; + + /// No description provided for @title. + /// + /// In en, this message translates to: + /// **'Title'** + String get title; + + /// No description provided for @description. + /// + /// In en, this message translates to: + /// **'Description'** + String get description; + + /// No description provided for @add. + /// + /// In en, this message translates to: + /// **'Add'** + String get add; + /// No description provided for @banking. /// /// In en, this message translates to: @@ -3320,12 +3362,6 @@ abstract class AppLocalizations { /// **'The world becomes beautiful through cooperation'** String get motto; - /// No description provided for @add. - /// - /// In en, this message translates to: - /// **'Add'** - String get add; - /// No description provided for @view. /// /// In en, this message translates to: @@ -3668,30 +3704,6 @@ abstract class AppLocalizations { /// **'Warehouse Transfers'** String get warehouseTransfers; - /// No description provided for @addAttribute. - /// - /// In en, this message translates to: - /// **'Add Attribute'** - String get addAttribute; - - /// No description provided for @viewAttributes. - /// - /// In en, this message translates to: - /// **'View Attributes'** - String get viewAttributes; - - /// No description provided for @editAttributes. - /// - /// In en, this message translates to: - /// **'Edit Attributes'** - String get editAttributes; - - /// No description provided for @deleteAttributes. - /// - /// In en, this message translates to: - /// **'Delete Attributes'** - String get deleteAttributes; - /// No description provided for @addBankAccount. /// /// In en, this message translates to: @@ -4957,6 +4969,108 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Post commission in invoice accounting document'** String get commissionPostInInvoiceDocument; + + /// No description provided for @manageCategories. + /// + /// In en, this message translates to: + /// **'Manage Categories'** + String get manageCategories; + + /// No description provided for @categoriesDialogTitle. + /// + /// In en, this message translates to: + /// **'Manage Categories'** + String get categoriesDialogTitle; + + /// No description provided for @addRootCategory. + /// + /// In en, this message translates to: + /// **'Add Root'** + String get addRootCategory; + + /// No description provided for @addChildCategory. + /// + /// In en, this message translates to: + /// **'Add Child'** + String get addChildCategory; + + /// No description provided for @renameCategory. + /// + /// In en, this message translates to: + /// **'Rename'** + String get renameCategory; + + /// No description provided for @deleteCategory. + /// + /// In en, this message translates to: + /// **'Delete Category'** + String get deleteCategory; + + /// No description provided for @deleteCategoryConfirm. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this category?'** + String get deleteCategoryConfirm; + + /// No description provided for @categoryNameFa. + /// + /// In en, this message translates to: + /// **'Name (Persian)'** + String get categoryNameFa; + + /// No description provided for @categoryNameEn. + /// + /// In en, this message translates to: + /// **'Name (English)'** + String get categoryNameEn; + + /// No description provided for @categoryType. + /// + /// In en, this message translates to: + /// **'Type'** + String get categoryType; + + /// No description provided for @productType. + /// + /// In en, this message translates to: + /// **'Product'** + String get productType; + + /// No description provided for @serviceType. + /// + /// In en, this message translates to: + /// **'Service'** + String get serviceType; + + /// No description provided for @loadingCategories. + /// + /// In en, this message translates to: + /// **'Loading categories...'** + String get loadingCategories; + + /// No description provided for @createCategory. + /// + /// In en, this message translates to: + /// **'Create Category'** + String get createCategory; + + /// No description provided for @updateCategory. + /// + /// In en, this message translates to: + /// **'Update Category'** + String get updateCategory; + + /// No description provided for @deleteCategorySuccess. + /// + /// In en, this message translates to: + /// **'Category deleted'** + String get deleteCategorySuccess; + + /// No description provided for @operationFailed. + /// + /// In en, this message translates to: + /// **'Operation failed'** + String get operationFailed; } class _AppLocalizationsDelegate diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index 2a275cd..2150e6a 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -1164,7 +1164,9 @@ class AppLocalizationsEn extends AppLocalizations { String get restoreFile => 'Restore File'; @override - String get deleteConfirm => 'Confirm Delete'; + String deleteConfirm(Object name) { + return 'Are you sure to delete \"$name\"?'; + } @override String get deleteConfirmMessage => @@ -1406,6 +1408,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get productAttributes => 'Product Attributes'; + @override + String get addAttribute => 'Add Attribute'; + + @override + String get viewAttributes => 'View Attributes'; + + @override + String get editAttributes => 'Edit Attributes'; + + @override + String get deleteAttributes => 'Delete Attributes'; + + @override + String get title => 'Title'; + + @override + String get description => 'Description'; + + @override + String get add => 'Add'; + @override String get banking => 'Banking'; @@ -1664,9 +1687,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get motto => 'The world becomes beautiful through cooperation'; - @override - String get add => 'Add'; - @override String get view => 'View'; @@ -1839,18 +1859,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get warehouseTransfers => 'Warehouse Transfers'; - @override - String get addAttribute => 'Add Attribute'; - - @override - String get viewAttributes => 'View Attributes'; - - @override - String get editAttributes => 'Edit Attributes'; - - @override - String get deleteAttributes => 'Delete Attributes'; - @override String get addBankAccount => 'Add Bank Account'; @@ -2503,4 +2511,56 @@ class AppLocalizationsEn extends AppLocalizations { @override String get commissionPostInInvoiceDocument => 'Post commission in invoice accounting document'; + + @override + String get manageCategories => 'Manage Categories'; + + @override + String get categoriesDialogTitle => 'Manage Categories'; + + @override + String get addRootCategory => 'Add Root'; + + @override + String get addChildCategory => 'Add Child'; + + @override + String get renameCategory => 'Rename'; + + @override + String get deleteCategory => 'Delete Category'; + + @override + String get deleteCategoryConfirm => + 'Are you sure you want to delete this category?'; + + @override + String get categoryNameFa => 'Name (Persian)'; + + @override + String get categoryNameEn => 'Name (English)'; + + @override + String get categoryType => 'Type'; + + @override + String get productType => 'Product'; + + @override + String get serviceType => 'Service'; + + @override + String get loadingCategories => 'Loading categories...'; + + @override + String get createCategory => 'Create Category'; + + @override + String get updateCategory => 'Update Category'; + + @override + String get deleteCategorySuccess => 'Category deleted'; + + @override + String get operationFailed => 'Operation failed'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 23abb09..80be4d8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -1158,7 +1158,9 @@ class AppLocalizationsFa extends AppLocalizations { String get restoreFile => 'بازیابی فایل'; @override - String get deleteConfirm => 'تایید حذف'; + String deleteConfirm(Object name) { + return 'آیا از حذف \"$name\" مطمئن هستید؟'; + } @override String get deleteConfirmMessage => 'آیا از حذف این فایل مطمئن هستید؟'; @@ -1395,6 +1397,27 @@ class AppLocalizationsFa extends AppLocalizations { @override String get productAttributes => 'ویژگی‌های کالا و خدمات'; + @override + String get addAttribute => 'افزودن ویژگی'; + + @override + String get viewAttributes => 'مشاهده ویژگی‌ها'; + + @override + String get editAttributes => 'ویرایش ویژگی‌ها'; + + @override + String get deleteAttributes => 'حذف ویژگی‌ها'; + + @override + String get title => 'عنوان'; + + @override + String get description => 'توضیحات'; + + @override + String get add => 'افزودن'; + @override String get banking => 'بانکداری'; @@ -1654,9 +1677,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get motto => 'جهان با تعاون زیبا می‌شود'; - @override - String get add => 'افزودن'; - @override String get view => 'مشاهده'; @@ -1829,18 +1849,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get warehouseTransfers => 'صدور حواله'; - @override - String get addAttribute => 'افزودن ویژگی'; - - @override - String get viewAttributes => 'مشاهده ویژگی‌ها'; - - @override - String get editAttributes => 'ویرایش ویژگی‌ها'; - - @override - String get deleteAttributes => 'حذف ویژگی‌ها'; - @override String get addBankAccount => 'افزودن حساب بانکی'; @@ -2485,4 +2493,55 @@ class AppLocalizationsFa extends AppLocalizations { @override String get commissionPostInInvoiceDocument => 'ثبت پورسانت در سند حسابداری فاکتور'; + + @override + String get manageCategories => 'مدیریت دسته‌بندی‌ها'; + + @override + String get categoriesDialogTitle => 'مدیریت دسته‌بندی‌ها'; + + @override + String get addRootCategory => 'افزودن ریشه'; + + @override + String get addChildCategory => 'افزودن زیرشاخه'; + + @override + String get renameCategory => 'تغییر نام'; + + @override + String get deleteCategory => 'حذف دسته‌بندی'; + + @override + String get deleteCategoryConfirm => 'آیا از حذف این دسته‌بندی مطمئن هستید؟'; + + @override + String get categoryNameFa => 'نام (فارسی)'; + + @override + String get categoryNameEn => 'نام (انگلیسی)'; + + @override + String get categoryType => 'نوع'; + + @override + String get productType => 'کالا'; + + @override + String get serviceType => 'خدمت'; + + @override + String get loadingCategories => 'در حال بارگذاری دسته‌بندی‌ها...'; + + @override + String get createCategory => 'ایجاد دسته‌بندی'; + + @override + String get updateCategory => 'به‌روزرسانی دسته‌بندی'; + + @override + String get deleteCategorySuccess => 'دسته‌بندی حذف شد'; + + @override + String get operationFailed => 'عملیات ناموفق بود'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index e82cfd5..310062b 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -25,6 +25,10 @@ import 'pages/business/users_permissions_page.dart'; import 'pages/business/accounts_page.dart'; import 'pages/business/settings_page.dart'; import 'pages/business/persons_page.dart'; +import 'pages/business/product_attributes_page.dart'; +import 'pages/business/products_page.dart'; +import 'pages/business/price_lists_page.dart'; +import 'pages/business/price_list_items_page.dart'; import 'pages/error_404_page.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -573,6 +577,80 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: 'product-attributes', + name: 'business_product_attributes', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: ProductAttributesPage( + businessId: businessId, + authStore: _authStore!, + ), + ); + }, + ), + GoRoute( + path: 'products', + name: 'business_products', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: ProductsPage( + businessId: businessId, + authStore: _authStore!, + ), + ); + }, + ), + GoRoute( + path: 'price-lists', + name: 'business_price_lists', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: PriceListsPage( + businessId: businessId, + authStore: _authStore!, + ), + ); + }, + ), + GoRoute( + path: 'price-lists/:price_list_id/items', + name: 'business_price_list_items', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + final priceListId = int.parse(state.pathParameters['price_list_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: PriceListItemsPage( + businessId: businessId, + priceListId: priceListId, + authStore: _authStore!, + ), + ); + }, + ), GoRoute( path: 'persons', name: 'business_persons', diff --git a/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart b/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart index 2e268a5..4c30916 100644 --- a/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart +++ b/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart @@ -187,6 +187,29 @@ class BusinessMembersResponse { } } +class CurrencyLite { + final int id; + final String code; + final String title; + final String symbol; + + CurrencyLite({ + required this.id, + required this.code, + required this.title, + required this.symbol, + }); + + factory CurrencyLite.fromJson(Map json) { + return CurrencyLite( + id: json['id'] as int, + code: json['code'] as String? ?? '', + title: json['title'] as String? ?? '', + symbol: json['symbol'] as String? ?? '', + ); + } +} + class BusinessWithPermission { final int id; final String name; @@ -200,6 +223,8 @@ class BusinessWithPermission { final bool isOwner; final String role; final Map permissions; + final CurrencyLite? defaultCurrency; + final List currencies; BusinessWithPermission({ required this.id, @@ -214,6 +239,8 @@ class BusinessWithPermission { required this.isOwner, required this.role, required this.permissions, + this.defaultCurrency, + this.currencies = const [], }); factory BusinessWithPermission.fromJson(Map json) { @@ -240,6 +267,12 @@ class BusinessWithPermission { isOwner: json['is_owner'] ?? false, role: json['role'] ?? 'عضو', permissions: Map.from(json['permissions'] ?? {}), + defaultCurrency: json['default_currency'] != null + ? CurrencyLite.fromJson(Map.from(json['default_currency'])) + : null, + currencies: (json['currencies'] as List? ?? const []) + .map((c) => CurrencyLite.fromJson(Map.from(c))) + .toList(), ); } } diff --git a/hesabixUI/hesabix_ui/lib/models/product_form_data.dart b/hesabixUI/hesabix_ui/lib/models/product_form_data.dart new file mode 100644 index 0000000..24c9d60 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/product_form_data.dart @@ -0,0 +1,196 @@ +class ProductFormData { + // Basic Information + String itemType; + String? code; + String name; + String? description; + int? categoryId; + + // Inventory + bool trackInventory; + int? reorderPoint; + int? minOrderQty; + int? leadTimeDays; + + // Pricing + num? baseSalesPrice; + num? basePurchasePrice; + String? baseSalesNote; + String? basePurchaseNote; + + // Units + int? mainUnitId; + int? secondaryUnitId; + num? unitConversionFactor; + + // Taxes + bool isSalesTaxable; + bool isPurchaseTaxable; + num? salesTaxRate; + num? purchaseTaxRate; + int? taxTypeId; + String? taxCode; + int? taxUnitId; + + // Attributes + Set selectedAttributeIds; + + ProductFormData({ + this.itemType = 'کالا', + this.code, + this.name = '', + this.description, + this.categoryId, + this.trackInventory = false, + this.reorderPoint, + this.minOrderQty, + this.leadTimeDays, + this.baseSalesPrice, + this.basePurchasePrice, + this.baseSalesNote, + this.basePurchaseNote, + this.mainUnitId, + this.secondaryUnitId, + this.unitConversionFactor, + this.isSalesTaxable = false, + this.isPurchaseTaxable = false, + this.salesTaxRate, + this.purchaseTaxRate, + this.taxTypeId, + this.taxCode, + this.taxUnitId, + Set? selectedAttributeIds, + }) : selectedAttributeIds = selectedAttributeIds ?? {}; + + ProductFormData copyWith({ + String? itemType, + String? code, + String? name, + String? description, + int? categoryId, + bool? trackInventory, + int? reorderPoint, + int? minOrderQty, + int? leadTimeDays, + num? baseSalesPrice, + num? basePurchasePrice, + String? baseSalesNote, + String? basePurchaseNote, + int? mainUnitId, + int? secondaryUnitId, + num? unitConversionFactor, + bool? isSalesTaxable, + bool? isPurchaseTaxable, + num? salesTaxRate, + num? purchaseTaxRate, + int? taxTypeId, + String? taxCode, + int? taxUnitId, + Set? selectedAttributeIds, + }) { + return ProductFormData( + itemType: itemType ?? this.itemType, + code: code ?? this.code, + name: name ?? this.name, + description: description ?? this.description, + categoryId: categoryId ?? this.categoryId, + trackInventory: trackInventory ?? this.trackInventory, + reorderPoint: reorderPoint ?? this.reorderPoint, + minOrderQty: minOrderQty ?? this.minOrderQty, + leadTimeDays: leadTimeDays ?? this.leadTimeDays, + baseSalesPrice: baseSalesPrice ?? this.baseSalesPrice, + basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice, + baseSalesNote: baseSalesNote ?? this.baseSalesNote, + basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote, + mainUnitId: mainUnitId ?? this.mainUnitId, + secondaryUnitId: secondaryUnitId ?? this.secondaryUnitId, + unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor, + isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable, + isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable, + salesTaxRate: salesTaxRate ?? this.salesTaxRate, + purchaseTaxRate: purchaseTaxRate ?? this.purchaseTaxRate, + taxTypeId: taxTypeId ?? this.taxTypeId, + taxCode: taxCode ?? this.taxCode, + taxUnitId: taxUnitId ?? this.taxUnitId, + selectedAttributeIds: selectedAttributeIds ?? this.selectedAttributeIds, + ); + } + + Map toPayload() { + return { + 'item_type': itemType, + 'code': code, + 'name': name, + 'description': description, + 'category_id': categoryId, + 'track_inventory': trackInventory, + 'base_sales_price': baseSalesPrice, + 'base_purchase_price': basePurchasePrice, + 'main_unit_id': mainUnitId, + 'secondary_unit_id': secondaryUnitId, + 'unit_conversion_factor': unitConversionFactor, + 'base_sales_note': baseSalesNote, + 'base_purchase_note': basePurchaseNote, + 'reorder_point': reorderPoint, + 'min_order_qty': minOrderQty, + 'lead_time_days': leadTimeDays, + 'is_sales_taxable': isSalesTaxable, + 'is_purchase_taxable': isPurchaseTaxable, + 'sales_tax_rate': salesTaxRate, + 'purchase_tax_rate': purchaseTaxRate, + 'tax_type_id': taxTypeId, + 'tax_code': taxCode, + 'tax_unit_id': taxUnitId, + 'attribute_ids': selectedAttributeIds.isEmpty ? null : selectedAttributeIds.toList(), + }..removeWhere((k, v) => v == null); + } + + factory ProductFormData.fromProduct(Map product) { + return ProductFormData( + itemType: (product['item_type'] as String?) ?? 'کالا', + code: product['code']?.toString(), + name: product['name'] ?? '', + description: product['description']?.toString(), + categoryId: product['category_id'] as int?, + trackInventory: (product['track_inventory'] == true), + baseSalesPrice: _parseNumeric(product['base_sales_price']), + basePurchasePrice: _parseNumeric(product['base_purchase_price']), + mainUnitId: product['main_unit_id'] as int?, + secondaryUnitId: product['secondary_unit_id'] as int?, + unitConversionFactor: _parseNumeric(product['unit_conversion_factor']), + baseSalesNote: product['base_sales_note']?.toString(), + basePurchaseNote: product['base_purchase_note']?.toString(), + reorderPoint: _parseInt(product['reorder_point']), + minOrderQty: _parseInt(product['min_order_qty']), + leadTimeDays: _parseInt(product['lead_time_days']), + isSalesTaxable: (product['is_sales_taxable'] == true), + isPurchaseTaxable: (product['is_purchase_taxable'] == true), + salesTaxRate: _parseNumeric(product['sales_tax_rate']), + purchaseTaxRate: _parseNumeric(product['purchase_tax_rate']), + taxTypeId: product['tax_type_id'] as int?, + taxCode: product['tax_code']?.toString(), + taxUnitId: product['tax_unit_id'] as int?, + selectedAttributeIds: _parseAttributeIds(product['attribute_ids']), + ); + } + + static num? _parseNumeric(dynamic value) { + if (value is num) return value; + if (value is String) return num.tryParse(value); + return null; + } + + static int? _parseInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + static Set _parseAttributeIds(dynamic value) { + if (value is List) { + return value.whereType().toSet(); + } + return {}; + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index 0ecbb75..fe2dc7e 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -6,6 +6,7 @@ import '../../core/calendar_controller.dart'; import '../../theme/theme_controller.dart'; import '../../widgets/combined_user_menu_button.dart'; import '../../widgets/person/person_form_dialog.dart'; +import '../../widgets/category/category_tree_dialog.dart'; import '../../services/business_dashboard_service.dart'; import '../../core/api_client.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; @@ -132,14 +133,6 @@ class _BusinessShellState extends State { type: _MenuItemType.simple, hasAddButton: true, ), - _MenuItem( - label: t.priceLists, - icon: Icons.list_alt, - selectedIcon: Icons.list_alt, - path: '/business/${widget.businessId}/price-lists', - type: _MenuItemType.simple, - hasAddButton: false, - ), _MenuItem( label: t.categories, icon: Icons.category, @@ -399,11 +392,11 @@ class _BusinessShellState extends State { final child = item.children![j]; if (child.path != null && location.startsWith(child.path!)) { selectedIndex = i; - // تنظیم وضعیت باز بودن منو - if (i == 2) _isProductsAndServicesExpanded = true; // کالا و خدمات در ایندکس 2 - if (i == 3) _isBankingExpanded = true; // بانکداری در ایندکس 3 - if (i == 5) _isAccountingMenuExpanded = true; // حسابداری در ایندکس 5 - if (i == 7) _isWarehouseManagementExpanded = true; // انبارداری در ایندکس 7 + // تنظیم وضعیت باز بودن منو بر اساس برچسب آیتم + if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = true; + if (item.label == t.banking) _isBankingExpanded = true; + if (item.label == t.accountingMenu) _isAccountingMenuExpanded = true; + if (item.label == t.warehouseManagement) _isWarehouseManagementExpanded = true; break; } } @@ -414,22 +407,48 @@ class _BusinessShellState extends State { final item = menuItems[index]; if (item.type == _MenuItemType.separator) return; // آیتم جداکننده قابل کلیک نیست - if (item.type == _MenuItemType.simple && item.path != null) { + if (item.type == _MenuItemType.simple && item.path != null) { try { if (GoRouterState.of(context).uri.toString() != item.path!) { - context.go(item.path!); + if (item.label == t.categories) { + // باز کردن دیالوگ دسته‌بندی‌ها به جای ناوبری + if (widget.authStore.canReadSection('categories')) { + await showDialog( + context: context, + builder: (ctx) => CategoryTreeDialog( + businessId: widget.businessId, + authStore: widget.authStore, + ), + ); + } + } else { + context.go(item.path!); + } } } catch (e) { // اگر GoRouterState در دسترس نیست، مستقیماً به مسیر برود - context.go(item.path!); + if (item.label == t.categories) { + if (widget.authStore.canReadSection('categories')) { + await showDialog( + context: context, + builder: (ctx) => CategoryTreeDialog( + businessId: widget.businessId, + authStore: widget.authStore, + ), + ); + } + } else { + context.go(item.path!); + } } } else if (item.type == _MenuItemType.expandable) { // تغییر وضعیت باز/بسته بودن منو - if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded; - if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded; - if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded; - if (item.label == t.warehouseManagement) _isWarehouseManagementExpanded = !_isWarehouseManagementExpanded; - setState(() {}); + setState(() { + if (item.label == t.productsAndServices) _isProductsAndServicesExpanded = !_isProductsAndServicesExpanded; + if (item.label == t.banking) _isBankingExpanded = !_isBankingExpanded; + if (item.label == t.accountingMenu) _isAccountingMenuExpanded = !_isAccountingMenuExpanded; + if (item.label == t.warehouseManagement) _isWarehouseManagementExpanded = !_isWarehouseManagementExpanded; + }); } } @@ -437,6 +456,18 @@ class _BusinessShellState extends State { final parent = menuItems[parentIndex]; if (parent.type == _MenuItemType.expandable && parent.children != null) { final child = parent.children![childIndex]; + if (child.label == t.categories) { + if (widget.authStore.canReadSection('categories')) { + await showDialog( + context: context, + builder: (ctx) => CategoryTreeDialog( + businessId: widget.businessId, + authStore: widget.authStore, + ), + ); + } + return; + } if (child.path != null) { try { if (GoRouterState.of(context).uri.toString() != child.path!) { @@ -650,8 +681,6 @@ class _BusinessShellState extends State { showAddPersonDialog(); } else if (child.label == t.products) { // Navigate to add product - } else if (child.label == t.priceLists) { - // Navigate to add price list } else if (child.label == t.categories) { // Navigate to add category } else if (child.label == t.productAttributes) { @@ -944,8 +973,6 @@ class _BusinessShellState extends State { // Navigate to add new item if (child.label == t.products) { // Navigate to add product - } else if (child.label == t.priceLists) { - // Navigate to add price list } else if (child.label == t.categories) { // Navigate to add category } else if (child.label == t.productAttributes) { @@ -1048,7 +1075,6 @@ class _BusinessShellState extends State { String? _sectionForLabel(String label, AppLocalizations t) { if (label == t.people) return 'people'; if (label == t.products) return 'products'; - if (label == t.priceLists) return 'price_lists'; if (label == t.categories) return 'categories'; if (label == t.productAttributes) return 'product_attributes'; if (label == t.accounts) return 'bank_accounts'; diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index 4921c06..6bbccda 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -130,11 +130,6 @@ class _PersonsPageState extends State { width: ColumnWidth.medium, formatter: (person) => person.nationalId ?? '-', ), - DateColumn( - 'created_at', - t.createdAt, - width: ColumnWidth.medium, - ), NumberColumn( 'share_count', t.shareCount, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart new file mode 100644 index 0000000..ff1869f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../core/auth_store.dart'; +import '../../services/price_list_service.dart'; +import '../../core/api_client.dart'; + +class PriceListItemsPage extends StatefulWidget { + final int businessId; + final int priceListId; + final AuthStore authStore; + final String? priceListName; + + const PriceListItemsPage({ + super.key, + required this.businessId, + required this.priceListId, + required this.authStore, + this.priceListName, + }); + + @override + State createState() => _PriceListItemsPageState(); +} + +class _PriceListItemsPageState extends State { + final _svc = PriceListService(apiClient: ApiClient()); + bool _loading = true; + List> _items = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + _items = await _svc.listItems(businessId: widget.businessId, priceListId: widget.priceListId); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در بارگذاری: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(widget.priceListName ?? t.priceLists), + actions: [ + IconButton(onPressed: _load, icon: const Icon(Icons.refresh)), + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _openEditor(), + tooltip: 'افزودن قیمت', + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final it = _items[i]; + return ListTile( + title: Text('کالا ${it['product_id']} - ${it['tier_name']}'), + subtitle: Text('حداقل ${it['min_qty']} - قیمت ${it['price']}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _openEditor(item: it), + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () async { + final ok = await _svc.deleteItem(businessId: widget.businessId, itemId: it['id'] as int); + if (ok) _load(); + }, + ), + ], + ), + ); + }, + ), + ); + } + + Future _openEditor({Map? item}) async { + final formKey = GlobalKey(); + int? productId = item?['product_id'] as int?; + String tierName = (item?['tier_name'] as String?) ?? 'تکی'; + int? unitId = item?['unit_id'] as int?; + num minQty = (item?['min_qty'] as num?) ?? 0; + num price = (item?['price'] as num?) ?? 0; + + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(item == null ? 'افزودن قیمت' : 'ویرایش قیمت'), + content: SizedBox( + width: 520, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: productId?.toString(), + decoration: const InputDecoration(labelText: 'شناسه کالا'), + keyboardType: TextInputType.number, + validator: (v) => (int.tryParse(v ?? '') == null) ? 'نامعتبر' : null, + onChanged: (v) => productId = int.tryParse(v), + ), + TextFormField( + initialValue: tierName, + decoration: const InputDecoration(labelText: 'نام پله (مثلاً: تکی/عمده/همکار)'), + validator: (v) => (v == null || v.trim().isEmpty) ? 'ضروری' : null, + onChanged: (v) => tierName = v, + ), + DropdownButtonFormField( + value: unitId, + items: _fallbackUnits + .map((u) => DropdownMenuItem( + value: u['id'] as int, + child: Text(u['title'] as String), + )) + .toList(), + onChanged: (v) => unitId = v, + decoration: const InputDecoration(labelText: 'واحد'), + ), + TextFormField( + initialValue: minQty.toString(), + decoration: const InputDecoration(labelText: 'حداقل تعداد'), + keyboardType: TextInputType.number, + validator: (v) => (num.tryParse(v ?? '') == null) ? 'نامعتبر' : null, + onChanged: (v) => minQty = num.tryParse(v) ?? 0, + ), + TextFormField( + initialValue: price.toString(), + decoration: const InputDecoration(labelText: 'قیمت'), + keyboardType: TextInputType.number, + validator: (v) => (num.tryParse(v ?? '') == null) ? 'نامعتبر' : null, + onChanged: (v) => price = num.tryParse(v) ?? 0, + ), + ], + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(AppLocalizations.of(ctx).cancel)), + FilledButton( + onPressed: () async { + if (!(formKey.currentState?.validate() ?? false)) return; + final payload = { + 'product_id': productId, + 'tier_name': tierName, + 'unit_id': unitId, + 'min_qty': minQty, + 'price': price, + }..removeWhere((k, v) => v == null); + await _svc.upsertItem( + businessId: widget.businessId, + priceListId: widget.priceListId, + payload: payload, + ); + if (mounted) Navigator.of(ctx).pop(true); + _load(); + }, + child: Text(AppLocalizations.of(ctx).save), + ), + ], + ), + ); + } + + List> get _fallbackUnits => [ + {'id': 1, 'title': 'عدد'}, + {'id': 2, 'title': 'کیلوگرم'}, + {'id': 3, 'title': 'لیتر'}, + ]; +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart new file mode 100644 index 0000000..3671f5b --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../widgets/data_table/data_table_widget.dart'; +import '../../widgets/data_table/data_table_config.dart'; +import 'package:go_router/go_router.dart'; +import 'price_list_items_page.dart'; +import '../../core/auth_store.dart'; + +class PriceListsPage extends StatelessWidget { + final int businessId; + final AuthStore authStore; + + const PriceListsPage({super.key, required this.businessId, required this.authStore}); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + return Scaffold( + body: DataTableWidget>( + config: DataTableConfig>( + endpoint: '/api/v1/price-lists/business/$businessId/search', + title: t.priceLists, + columns: [ + TextColumn('name', t.title, width: ColumnWidth.large), + TextColumn('currency_id', 'ارز', width: ColumnWidth.small), + TextColumn('default_unit_id', 'واحد پیش‌فرض', width: ColumnWidth.small), + TextColumn('created_at_formatted', t.createdAt, width: ColumnWidth.medium), + ], + searchFields: const ['name'], + filterFields: const [], + defaultPageSize: 20, + onRowDoubleTap: (row) { + final id = row['id'] as int?; + if (id != null) { + context.go('/business/$businessId/price-lists/$id/items'); + } + }, + ), + fromJson: (json) => json, + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/product_attributes_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/product_attributes_page.dart new file mode 100644 index 0000000..aaff030 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/product_attributes_page.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../widgets/data_table/data_table_widget.dart'; +import '../../widgets/data_table/data_table_config.dart'; +import '../../widgets/permission/permission_widgets.dart'; +import '../../core/auth_store.dart'; +import '../../services/product_attribute_service.dart'; + +class ProductAttributeItem { + final int id; + final int businessId; + final String title; + final String? description; + final DateTime createdAt; + final DateTime updatedAt; + // Display strings coming from backend when calendar = jalali/gregorian + final String? createdAtDisplay; + final String? updatedAtDisplay; + + ProductAttributeItem({ + required this.id, + required this.businessId, + required this.title, + this.description, + required this.createdAt, + required this.updatedAt, + this.createdAtDisplay, + this.updatedAtDisplay, + }); + + static ProductAttributeItem fromJson(Map json) { + final dynamic createdRaw = json['created_at']; + final dynamic updatedRaw = json['updated_at']; + + final String? createdDisplay = _extractDisplay(createdRaw); + final String? updatedDisplay = _extractDisplay(updatedRaw); + + return ProductAttributeItem( + id: json['id'] as int, + businessId: json['business_id'] as int, + title: json['title'] as String, + description: json['description'] as String?, + createdAt: _parseDate(createdRaw), + updatedAt: _parseDate(updatedRaw), + createdAtDisplay: createdDisplay, + updatedAtDisplay: updatedDisplay, + ); + } + + static DateTime _parseDate(dynamic v) { + if (v is String) { + // Try ISO first + try { return DateTime.parse(v); } catch (_) {} + // If looks like jalali formatted (e.g., 1403/07/01 ...), just return now for sorting fallback + return DateTime.now(); + } + if (v is Map) { + // Try ISO-like fields first + final s = (v['iso'] ?? v['date_time'] ?? '').toString(); + if (s.isNotEmpty) { + try { return DateTime.parse(s); } catch (_) {} + } + // Fallback: construct from components (assumed Gregorian components) + final y = v['year']; + final m = v['month']; + final d = v['day']; + final hh = v['hour'] ?? 0; + final mm = v['minute'] ?? 0; + final ss = v['second'] ?? 0; + if (y is int && m is int && d is int) { + try { return DateTime(y, m, d, hh is int ? hh : 0, mm is int ? mm : 0, ss is int ? ss : 0); } catch (_) {} + } + return DateTime.now(); + } + return DateTime.now(); + } + + static String? _extractDisplay(dynamic v) { + if (v is String) { + return v.trim().isEmpty ? null : v; + } + if (v is Map) { + final s = (v['formatted'] ?? v['date_time'] ?? '').toString(); + return s.isEmpty ? null : s; + } + return null; + } +} + +class ProductAttributesPage extends StatefulWidget { + final int businessId; + final AuthStore authStore; + + const ProductAttributesPage({super.key, required this.businessId, required this.authStore}); + + @override + State createState() => _ProductAttributesPageState(); +} + +class _ProductAttributesPageState extends State { + final _service = ProductAttributeService(); + final GlobalKey _tableKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + if (!widget.authStore.canReadSection('product_attributes')) { + return const AccessDeniedPage(); + } + + return Scaffold( + body: DataTableWidget( + key: _tableKey, + config: _buildConfig(t), + fromJson: ProductAttributeItem.fromJson, + ), + ); + } + + DataTableConfig _buildConfig(AppLocalizations t) { + return DataTableConfig( + endpoint: '/api/v1/product-attributes/business/${widget.businessId}/search', + title: t.productAttributes, + columns: [ + TextColumn('title', t.title, width: ColumnWidth.large, formatter: (e) => e.title), + TextColumn('description', t.description, width: ColumnWidth.extraLarge, formatter: (e) => e.description ?? '-'), + DateColumn('created_at', t.createdAt, formatter: (e) => _formatDateFromItem(e, context, isUpdated: false)), + DateColumn('updated_at', t.updatedAt, formatter: (e) => _formatDateFromItem(e, context, isUpdated: true)), + ActionColumn('actions', t.actions, actions: [ + DataTableAction(icon: Icons.edit, label: t.edit, onTap: (e) => _openForm(editing: e)), + DataTableAction(icon: Icons.delete, label: t.delete, color: Colors.red, onTap: (e) => _confirmDelete(e)), + ]), + ], + searchFields: ['title', 'description'], + defaultPageSize: 20, + customHeaderActions: [ + PermissionButton( + section: 'product_attributes', + action: 'add', + authStore: widget.authStore, + child: Tooltip( + message: t.addAttribute, + child: IconButton(onPressed: () => _openForm(), icon: const Icon(Icons.add)), + ), + ), + ], + ); + } + + static String _formatDate(DateTime dt, BuildContext context) { + final y = dt.year.toString().padLeft(4, '0'); + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + final hh = dt.hour.toString().padLeft(2, '0'); + final mm = dt.minute.toString().padLeft(2, '0'); + return '$y/$m/$d $hh:$mm'; + } + + static String _formatDateFromItem(ProductAttributeItem e, BuildContext context, {required bool isUpdated}) { + final display = isUpdated ? e.updatedAtDisplay : e.createdAtDisplay; + if (display != null && display.isNotEmpty) { + return display; // Respect backend calendar-formatted string + } + return _formatDate(isUpdated ? e.updatedAt : e.createdAt, context); + } + + void _openForm({ProductAttributeItem? editing}) async { + final t = AppLocalizations.of(context); + final titleCtrl = TextEditingController(text: editing?.title ?? ''); + final descCtrl = TextEditingController(text: editing?.description ?? ''); + + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(editing == null ? t.add : t.edit), + content: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: titleCtrl, decoration: InputDecoration(labelText: t.title)), + const SizedBox(height: 8), + TextField(controller: descCtrl, maxLines: 3, decoration: InputDecoration(labelText: t.description)), + const SizedBox(height: 8), + const SizedBox.shrink(), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.cancel)), + FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(t.save)), + ], + ), + ); + if (result == true) { + if (editing == null) { + await _service.create(businessId: widget.businessId, title: titleCtrl.text.trim(), description: descCtrl.text.trim().isEmpty ? null : descCtrl.text.trim()); + } else { + await _service.update(businessId: widget.businessId, id: editing.id, title: titleCtrl.text.trim(), description: descCtrl.text.trim()); + } + try { + // ignore: avoid_dynamic_calls + ( _tableKey.currentState as dynamic )?.refresh(); + } catch (_) {} + } + } + + void _confirmDelete(ProductAttributeItem item) { + final t = AppLocalizations.of(context); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.delete), + content: Text(t.deleteConfirm(item.title)), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(t.cancel)), + TextButton(onPressed: () async { + Navigator.pop(context); + await _service.delete(businessId: widget.businessId, id: item.id); + try { ( _tableKey.currentState as dynamic )?.refresh(); } catch (_) {} + }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: Text(t.delete)), + ], + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart new file mode 100644 index 0000000..651a519 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../widgets/data_table/data_table_widget.dart'; +import '../../widgets/data_table/data_table_config.dart'; +import '../../widgets/product/product_form_dialog.dart'; +import '../../core/auth_store.dart'; + +class ProductsPage extends StatefulWidget { + final int businessId; + final AuthStore authStore; + + const ProductsPage({super.key, required this.businessId, required this.authStore}); + + @override + State createState() => _ProductsPageState(); +} + +class _ProductsPageState extends State { + final GlobalKey _tableKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + if (!widget.authStore.canReadSection('products')) { + return Scaffold( + body: Center(child: Text('دسترسی مشاهده کالا و خدمات را ندارید')), + ); + } + + return Scaffold( + body: DataTableWidget>( + key: _tableKey, + config: DataTableConfig>( + endpoint: '/api/v1/products/business/${widget.businessId}/search', + title: t.products, + excelEndpoint: '/api/v1/products/business/${widget.businessId}/export/excel', + pdfEndpoint: '/api/v1/products/business/${widget.businessId}/export/pdf', + columns: [ + TextColumn('code', t.code, width: ColumnWidth.small), + TextColumn('name', t.title, width: ColumnWidth.large), + TextColumn('item_type', t.service, width: ColumnWidth.small), + NumberColumn('base_sales_price', 'قیمت فروش', width: ColumnWidth.medium, decimalPlaces: 2), + NumberColumn('base_purchase_price', 'قیمت خرید', width: ColumnWidth.medium, decimalPlaces: 2), + // Inventory + TextColumn( + 'track_inventory', + 'کنترل موجودی', + width: ColumnWidth.small, + formatter: (row) => (row['track_inventory'] == true) ? 'بله' : 'خیر', + ), + NumberColumn('reorder_point', 'نقطه سفارش', width: ColumnWidth.small, decimalPlaces: 0), + NumberColumn('min_order_qty', 'کمینه سفارش', width: ColumnWidth.small, decimalPlaces: 0), + // Taxes + NumberColumn('sales_tax_rate', 'مالیات فروش %', width: ColumnWidth.small, decimalPlaces: 2), + NumberColumn('purchase_tax_rate', 'مالیات خرید %', width: ColumnWidth.small, decimalPlaces: 2), + TextColumn('tax_code', 'کُد مالیاتی', width: ColumnWidth.small), + TextColumn('created_at_formatted', t.createdAt, width: ColumnWidth.medium), + ActionColumn('actions', t.actions, actions: [ + DataTableAction( + icon: Icons.edit, + label: t.edit, + onTap: (row) async { + await showDialog( + context: context, + builder: (ctx) => ProductFormDialog( + businessId: widget.businessId, + authStore: widget.authStore, + product: row, + onSuccess: () { + try { + ( _tableKey.currentState as dynamic)?.refresh(); + } catch (_) {} + }, + ), + ); + }, + ), + ]), + ], + searchFields: const ['code', 'name', 'description'], + filterFields: const ['item_type', 'category_id'], + defaultPageSize: 20, + customHeaderActions: [ + Tooltip( + message: t.addProduct, + child: IconButton( + onPressed: () async { + await showDialog( + context: context, + builder: (ctx) => ProductFormDialog( + businessId: widget.businessId, + authStore: widget.authStore, + onSuccess: () { + try { + ( _tableKey.currentState as dynamic)?.refresh(); + } catch (_) {} + }, + ), + ); + }, + icon: const Icon(Icons.add), + ), + ), + ], + ), + fromJson: (json) => json, + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart index a898f91..fc61d91 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/businesses_page.dart @@ -4,6 +4,7 @@ import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../services/business_dashboard_service.dart'; import '../../core/api_client.dart'; import '../../models/business_dashboard_models.dart'; +import '../../core/auth_store.dart'; class BusinessesPage extends StatefulWidget { const BusinessesPage({super.key}); @@ -17,11 +18,19 @@ class _BusinessesPageState extends State { List _businesses = []; bool _loading = true; String? _error; + final AuthStore _authStore = AuthStore(); @override void initState() { super.initState(); - _loadBusinesses(); + _init(); + } + + Future _init() async { + // اطمینان از bind بودن AuthStore برای ApiClient + ApiClient.bindAuthStore(_authStore); + await _authStore.load(); + await _loadBusinesses(); } Future _loadBusinesses() async { @@ -141,6 +150,7 @@ class _BusinessesPageState extends State { return _BusinessCard( business: business, onTap: () => _navigateToBusiness(business.id), + authStore: _authStore, isCompact: crossAxisCount > 1, ); }, @@ -154,20 +164,42 @@ class _BusinessesPageState extends State { } } -class _BusinessCard extends StatelessWidget { +class _BusinessCard extends StatefulWidget { final BusinessWithPermission business; final VoidCallback onTap; final bool isCompact; + final AuthStore authStore; const _BusinessCard({ required this.business, required this.onTap, + required this.authStore, this.isCompact = true, }); + @override + State<_BusinessCard> createState() => _BusinessCardState(); +} + +class _BusinessCardState extends State<_BusinessCard> { + String? _localCurrencyCode; + + @override + void initState() { + super.initState(); + _localCurrencyCode = _resolveInitialCurrency(); + } + + String? _resolveInitialCurrency() { + final codes = widget.business.currencies.map((c) => c.code).toSet(); + final authCode = widget.authStore.selectedCurrencyCode; + if (authCode != null && codes.contains(authCode)) return authCode; + return widget.business.defaultCurrency?.code ?? (widget.business.currencies.isNotEmpty ? widget.business.currencies.first.code : null); + } + @override Widget build(BuildContext context) { - if (isCompact) { + if (widget.isCompact) { return _buildCompactCard(context); } else { return _buildWideCard(context); @@ -179,7 +211,7 @@ class _BusinessCard extends StatelessWidget { elevation: 1, margin: EdgeInsets.zero, child: InkWell( - onTap: onTap, + onTap: widget.onTap, borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.all(8.0), @@ -193,14 +225,14 @@ class _BusinessCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: business.isOwner + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( - business.isOwner ? Icons.business : Icons.business_outlined, - color: business.isOwner + widget.business.isOwner ? Icons.business : Icons.business_outlined, + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.secondary, size: 20, @@ -211,15 +243,15 @@ class _BusinessCard extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: business.isOwner + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Text( - business.isOwner ? AppLocalizations.of(context).owner : AppLocalizations.of(context).member, + widget.business.isOwner ? AppLocalizations.of(context).owner : AppLocalizations.of(context).member, style: TextStyle( - color: business.isOwner + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.secondary, fontSize: 10, @@ -235,7 +267,7 @@ class _BusinessCard extends StatelessWidget { // Business name Text( - business.name, + widget.business.name, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, fontSize: 14, @@ -248,7 +280,7 @@ class _BusinessCard extends StatelessWidget { // Business type and field Text( - '${_translateBusinessType(business.businessType, context)} • ${_translateBusinessField(business.businessField, context)}', + '${_translateBusinessType(widget.business.businessType, context)} • ${_translateBusinessField(widget.business.businessField, context)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, @@ -259,20 +291,11 @@ class _BusinessCard extends StatelessWidget { const SizedBox(height: 6), - // Footer with date and arrow + // Footer with currency selector and arrow Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: Text( - _formatDate(business.createdAt), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 10, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: _buildCurrencyDropdown(context), ), Icon( Icons.arrow_forward_ios, @@ -287,13 +310,13 @@ class _BusinessCard extends StatelessWidget { ), ); } - + Widget _buildWideCard(BuildContext context) { return Card( elevation: 1, margin: EdgeInsets.zero, child: InkWell( - onTap: onTap, + onTap: widget.onTap, borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.all(16.0), @@ -303,14 +326,14 @@ class _BusinessCard extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: business.isOwner + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( - business.isOwner ? Icons.business : Icons.business_outlined, - color: business.isOwner + widget.business.isOwner ? Icons.business : Icons.business_outlined, + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.secondary, size: 24, @@ -328,7 +351,7 @@ class _BusinessCard extends StatelessWidget { children: [ Expanded( child: Text( - business.name, + widget.business.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -339,15 +362,15 @@ class _BusinessCard extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: business.isOwner + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) : Theme.of(context).colorScheme.secondary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Text( - business.isOwner ? AppLocalizations.of(context).owner : AppLocalizations.of(context).member, + widget.business.isOwner ? AppLocalizations.of(context).owner : AppLocalizations.of(context).member, style: TextStyle( - color: business.isOwner + color: widget.business.isOwner ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.secondary, fontSize: 12, @@ -361,7 +384,7 @@ class _BusinessCard extends StatelessWidget { const SizedBox(height: 4), Text( - '${_translateBusinessType(business.businessType, context)} • ${_translateBusinessField(business.businessField, context)}', + '${_translateBusinessType(widget.business.businessType, context)} • ${_translateBusinessField(widget.business.businessField, context)}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -372,7 +395,7 @@ class _BusinessCard extends StatelessWidget { const SizedBox(height: 8), Text( - 'تأسیس: ${_formatDate(business.createdAt)}', + 'تأسیس: ${_formatDate(widget.business.createdAt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -383,12 +406,13 @@ class _BusinessCard extends StatelessWidget { const SizedBox(width: 16), - // Arrow - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, + // Currency selector and Arrow + SizedBox( + width: 220, + child: _buildCurrencyDropdown(context), ), + const SizedBox(width: 8), + Icon(Icons.arrow_forward_ios, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), ], ), ), @@ -396,51 +420,77 @@ class _BusinessCard extends StatelessWidget { ); } - String _formatDate(String dateString) { - try { - final date = DateTime.parse(dateString); - return '${date.year}/${date.month}/${date.day}'; - } catch (e) { - return dateString; - } + Widget _buildCurrencyDropdown(BuildContext context) { + final items = widget.business.currencies; + final value = _localCurrencyCode ?? _resolveInitialCurrency(); + return DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + hint: const Text('انتخاب ارز'), + items: items + .map((c) => DropdownMenuItem( + value: c.code, + child: Text('${c.title} (${c.code})'), + )) + .toList(), + onChanged: (val) async { + if (val == null) return; + setState(() { + _localCurrencyCode = val; + }); + final selected = items.firstWhere((c) => c.code == val, orElse: () => items.first); + await widget.authStore.setSelectedCurrency(code: selected.code, id: selected.id); + }, + ), + ); } +} - String _translateBusinessType(String type, BuildContext context) { - final l10n = AppLocalizations.of(context); - switch (type) { - case 'شرکت': - return l10n.company; - case 'مغازه': - return l10n.shop; - case 'فروشگاه': - return l10n.store; - case 'اتحادیه': - return l10n.union; - case 'باشگاه': - return l10n.club; - case 'موسسه': - return l10n.institute; - case 'شخصی': - return l10n.individual; - default: - return type; - } +String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + return '${date.year}/${date.month}/${date.day}'; + } catch (e) { + return dateString; } +} - String _translateBusinessField(String field, BuildContext context) { - final l10n = AppLocalizations.of(context); - switch (field) { - case 'تولیدی': - return l10n.manufacturing; - case 'بازرگانی': - return l10n.trading; - case 'خدماتی': - return l10n.service; - case 'سایر': - return l10n.other; - default: - return field; - } +String _translateBusinessType(String type, BuildContext context) { + final l10n = AppLocalizations.of(context); + switch (type) { + case 'شرکت': + return l10n.company; + case 'مغازه': + return l10n.shop; + case 'فروشگاه': + return l10n.store; + case 'اتحادیه': + return l10n.union; + case 'باشگاه': + return l10n.club; + case 'موسسه': + return l10n.institute; + case 'شخصی': + return l10n.individual; + default: + return type; + } +} + +String _translateBusinessField(String field, BuildContext context) { + final l10n = AppLocalizations.of(context); + switch (field) { + case 'تولیدی': + return l10n.manufacturing; + case 'بازرگانی': + return l10n.trading; + case 'خدماتی': + return l10n.service; + case 'سایر': + return l10n.other; + default: + return field; } } diff --git a/hesabixUI/hesabix_ui/lib/services/category_service.dart b/hesabixUI/hesabix_ui/lib/services/category_service.dart new file mode 100644 index 0000000..21aa149 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/category_service.dart @@ -0,0 +1,118 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; + +class CategoryService { + final ApiClient _apiClient; + + CategoryService(this._apiClient); + + Future>> getTree({ + required int businessId, + String? type, // 'product' | 'service' + }) async { + try { + final res = await _apiClient.post>( + '/api/v1/categories/business/$businessId/tree', + data: type != null ? {'type': type} : null, + ); + final data = res.data?['data']; + final items = (data is Map) ? data['items'] : null; + if (items is List) { + return items.map>((e) => Map.from(e)).toList(); + } + return const >[]; + } on DioException catch (e) { + throw Exception(e.message); + } + } + + Future> create({ + required int businessId, + int? parentId, + required String type, // 'product' | 'service' + required String label, + }) async { + try { + final res = await _apiClient.post>( + '/api/v1/categories/business/$businessId', + data: { + 'parent_id': parentId, + 'type': type, + 'label': label, + }, + ); + final data = res.data?['data']; + final item = (data is Map) ? data['item'] : null; + return Map.from(item ?? const {}); + } on DioException catch (e) { + throw Exception(e.message); + } + } + + Future> update({ + required int businessId, + required int categoryId, + String? type, // optional + String? label, + }) async { + try { + final body = {}; + body['category_id'] = categoryId; + if (type != null) body['type'] = type; + if (label != null) body['label'] = label; + final res = await _apiClient.post>( + '/api/v1/categories/business/$businessId/update', + data: body, + ); + final data = res.data?['data']; + final item = (data is Map) ? data['item'] : null; + return Map.from(item ?? const {}); + } on DioException catch (e) { + throw Exception(e.message); + } + } + + Future> move({ + required int businessId, + required int categoryId, + int? newParentId, + }) async { + try { + final res = await _apiClient.post>( + '/api/v1/categories/business/$businessId/move', + data: { + 'category_id': categoryId, + 'new_parent_id': newParentId, + }, + ); + final data = res.data?['data']; + final item = (data is Map) ? data['item'] : null; + return Map.from(item ?? const {}); + } on DioException catch (e) { + throw Exception(e.message); + } + } + + Future delete({ + required int businessId, + required int categoryId, + }) async { + try { + final res = await _apiClient.post>( + '/api/v1/categories/business/$businessId/delete', + data: { + 'category_id': categoryId, + }, + ); + final data = res.data?['data']; + if (data is Map) { + return data['deleted'] == true; + } + return false; + } on DioException catch (e) { + throw Exception(e.message); + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/price_list_service.dart b/hesabixUI/hesabix_ui/lib/services/price_list_service.dart new file mode 100644 index 0000000..703faac --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/price_list_service.dart @@ -0,0 +1,64 @@ +import '../core/api_client.dart'; + +class PriceListService { + final ApiClient _api; + + PriceListService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + Future> listPriceLists({ + required int businessId, + int page = 1, + int limit = 20, + String? search, + }) async { + final body = { + 'take': limit, + 'skip': (page - 1) * limit, + if (search != null && search.isNotEmpty) 'search': search, + }; + final res = await _api.post>( + '/api/v1/price-lists/business/$businessId/search', + data: body, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future>> listItems({ + required int businessId, + required int priceListId, + }) async { + final res = await _api.get>( + '/api/v1/price-lists/business/$businessId/$priceListId/items', + ); + final data = res.data?['data']; + final items = (data is Map) ? data['items'] : null; + if (items is List) { + return items.map>((e) => Map.from(e)).toList(); + } + return const >[]; + } + + Future> upsertItem({ + required int businessId, + required int priceListId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/price-lists/business/$businessId/$priceListId/items', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future deleteItem({ + required int businessId, + required int itemId, + }) async { + final res = await _api.delete( + '/api/v1/price-lists/business/$businessId/items/$itemId', + ); + return res.statusCode == 200 && (res.data['data']?['deleted'] == true); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart b/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart new file mode 100644 index 0000000..9ff5c12 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; + +class ProductAttributeService { + final ApiClient _apiClient; + ProductAttributeService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + Future> search({ + required int businessId, + int page = 1, + int limit = 20, + String? search, + String? sortBy, + bool sortDesc = true, + }) async { + final body = { + 'take': limit, + 'skip': (page - 1) * limit, + 'sort_desc': sortDesc, + }; + if (search != null && search.isNotEmpty) body['search'] = search; + if (sortBy != null && sortBy.isNotEmpty) body['sort_by'] = sortBy; + + final res = await _apiClient.post>( + '/api/v1/product-attributes/business/$businessId/search', + data: body, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> create({ + required int businessId, + required String title, + String? description, + }) async { + final res = await _apiClient.post>( + '/api/v1/product-attributes/business/$businessId', + data: { + 'title': title, + if (description != null) 'description': description, + }, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> getOne({ + required int businessId, + required int id, + }) async { + final res = await _apiClient.get>( + '/api/v1/product-attributes/business/$businessId/$id', + ); + return Map.from(res.data?['data']?['item'] ?? const {}); + } + + Future> update({ + required int businessId, + required int id, + String? title, + String? description, + }) async { + final body = {}; + if (title != null) body['title'] = title; + if (description != null) body['description'] = description; + final res = await _apiClient.put>( + '/api/v1/product-attributes/business/$businessId/$id', + data: body, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future delete({ + required int businessId, + required int id, + }) async { + final res = await _apiClient.delete>( + '/api/v1/product-attributes/business/$businessId/$id', + ); + final data = res.data?['data']; + if (data is Map) return data['deleted'] == true; + return false; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/product_service.dart b/hesabixUI/hesabix_ui/lib/services/product_service.dart new file mode 100644 index 0000000..cc51e88 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/product_service.dart @@ -0,0 +1,80 @@ +import '../core/api_client.dart'; + +class ProductService { + final ApiClient _api; + + ProductService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + Future> createProduct({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/products/business/$businessId', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> getProduct({ + required int businessId, + required int productId, + }) async { + final res = await _api.get>( + '/api/v1/products/business/$businessId/$productId', + ); + return Map.from(res.data?['data']?['item'] ?? const {}); + } + + Future> updateProduct({ + required int businessId, + required int productId, + required Map payload, + }) async { + final res = await _api.put>( + '/api/v1/products/business/$businessId/$productId', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future deleteProduct({required int businessId, required int productId}) async { + final res = await _api.delete('/api/v1/products/business/$businessId/$productId'); + return res.statusCode == 200 && (res.data['data']?['deleted'] == true); + } + + Future codeExists({ + required int businessId, + required String code, + int? excludeProductId, + }) async { + final body = { + 'take': 1, + 'skip': 0, + 'filters': [ + { + 'property': 'code', + 'operator': '=', + 'value': code, + }, + ], + }; + final res = await _api.post>( + '/api/v1/products/business/$businessId/search', + data: body, + ); + final data = res.data?['data']; + final items = (data is Map) ? data['items'] : null; + if (items is List && items.isNotEmpty) { + final first = Map.from(items.first); + final foundId = first['id'] as int?; + if (excludeProductId != null && foundId == excludeProductId) { + return false; + } + return true; + } + return false; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/tax_service.dart b/hesabixUI/hesabix_ui/lib/services/tax_service.dart new file mode 100644 index 0000000..07f340d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/tax_service.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; + +class TaxService { + final ApiClient _apiClient; + TaxService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + Future>> getTaxTypes({required int businessId}) async { + try { + final res = await _apiClient.get>( + '/api/v1/tax-types/business/$businessId', + ); + final data = res.data?['data']; + if (data is List) { + return data + .map>((e) => Map.from(e as Map)) + .toList(); + } + if (data is Map && data['items'] is List) { + final items = data['items'] as List; + return items + .map>((e) => Map.from(e as Map)) + .toList(); + } + return const >[]; + } on DioException { + return const >[]; + } + } + + Future>> getTaxUnits({required int businessId}) async { + try { + final res = await _apiClient.get>( + '/api/v1/tax-units/business/$businessId', + ); + final data = res.data?['data']; + if (data is List) { + return data + .map>((e) => Map.from(e as Map)) + .toList(); + } + if (data is Map && data['items'] is List) { + final items = data['items'] as List; + return items + .map>((e) => Map.from(e as Map)) + .toList(); + } + return const >[]; + } on DioException { + return const >[]; + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/unit_service.dart b/hesabixUI/hesabix_ui/lib/services/unit_service.dart new file mode 100644 index 0000000..9ba62f4 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/unit_service.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; + +class UnitService { + final ApiClient _apiClient; + UnitService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + Future>> getUnits({required int businessId}) async { + try { + final res = await _apiClient.get>( + '/api/v1/units/business/$businessId', + ); + final data = res.data?['data']; + final items = (data is Map) ? data['items'] : null; + if (items is List) { + return items.map>((e) => Map.from(e)).toList(); + } + return const >[]; + } on DioException { + // Endpoint may not exist yet; return empty to allow UI fallback + return const >[]; + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart b/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart new file mode 100644 index 0000000..8a30d4a --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart @@ -0,0 +1,154 @@ +import '../../models/product_form_data.dart'; + +class ProductFormValidator { + static String? validateName(String? value) { + if (value == null || value.trim().isEmpty) { + return 'نام کالا الزامی است'; + } + if (value.trim().length < 2) { + return 'نام کالا باید حداقل ۲ کاراکتر باشد'; + } + return null; + } + + static String? validateCode(String? value) { + if (value != null && value.trim().isNotEmpty) { + if (value.trim().length < 2) { + return 'کد کالا باید حداقل ۲ کاراکتر باشد'; + } + // Add more code validation rules if needed + } + return null; + } + + static String? validatePrice(String? value, {String fieldName = 'قیمت'}) { + if (value != null && value.trim().isNotEmpty) { + final price = num.tryParse(value); + if (price == null) { + return '$fieldName باید عدد معتبر باشد'; + } + if (price < 0) { + return '$fieldName نمی‌تواند منفی باشد'; + } + } + return null; + } + + static String? validateQuantity(String? value, {String fieldName = 'مقدار'}) { + if (value != null && value.trim().isNotEmpty) { + final quantity = int.tryParse(value); + if (quantity == null) { + return '$fieldName باید عدد صحیح باشد'; + } + if (quantity < 0) { + return '$fieldName نمی‌تواند منفی باشد'; + } + } + return null; + } + + static String? validateConversionFactor(String? value) { + if (value != null && value.trim().isNotEmpty) { + final factor = num.tryParse(value); + if (factor == null) { + return 'ضریب تبدیل باید عدد معتبر باشد'; + } + if (factor <= 0) { + return 'ضریب تبدیل باید بزرگتر از صفر باشد'; + } + } + return null; + } + + static String? validateTaxRate(String? value, {String fieldName = 'نرخ مالیات'}) { + if (value != null && value.trim().isNotEmpty) { + final rate = num.tryParse(value); + if (rate == null) { + return '$fieldName باید عدد معتبر باشد'; + } + if (rate < 0) { + return '$fieldName نمی‌تواند منفی باشد'; + } + if (rate > 100) { + return '$fieldName نمی‌تواند بیشتر از ۱۰۰٪ باشد'; + } + } + return null; + } + + static String? validateLeadTime(String? value) { + if (value != null && value.trim().isNotEmpty) { + final days = int.tryParse(value); + if (days == null) { + return 'زمان تحویل باید عدد صحیح باشد'; + } + if (days < 0) { + return 'زمان تحویل نمی‌تواند منفی باشد'; + } + if (days > 365) { + return 'زمان تحویل نمی‌تواند بیشتر از ۳۶۵ روز باشد'; + } + } + return null; + } + + static Map validateFormData(ProductFormData formData) { + final errors = {}; + + // Required fields + if (formData.name.trim().isEmpty) { + errors['name'] = 'نام کالا الزامی است'; + } + + // Optional field validations + if (formData.baseSalesPrice != null && formData.baseSalesPrice! < 0) { + errors['baseSalesPrice'] = 'قیمت فروش نمی‌تواند منفی باشد'; + } + + if (formData.basePurchasePrice != null && formData.basePurchasePrice! < 0) { + errors['basePurchasePrice'] = 'قیمت خرید نمی‌تواند منفی باشد'; + } + + if (formData.unitConversionFactor != null && formData.unitConversionFactor! <= 0) { + errors['unitConversionFactor'] = 'ضریب تبدیل باید بزرگتر از صفر باشد'; + } + + if (formData.salesTaxRate != null) { + if (formData.salesTaxRate! < 0) { + errors['salesTaxRate'] = 'نرخ مالیات فروش نمی‌تواند منفی باشد'; + } else if (formData.salesTaxRate! > 100) { + errors['salesTaxRate'] = 'نرخ مالیات فروش نمی‌تواند بیشتر از ۱۰۰٪ باشد'; + } + } + + if (formData.purchaseTaxRate != null) { + if (formData.purchaseTaxRate! < 0) { + errors['purchaseTaxRate'] = 'نرخ مالیات خرید نمی‌تواند منفی باشد'; + } else if (formData.purchaseTaxRate! > 100) { + errors['purchaseTaxRate'] = 'نرخ مالیات خرید نمی‌تواند بیشتر از ۱۰۰٪ باشد'; + } + } + + if (formData.reorderPoint != null && formData.reorderPoint! < 0) { + errors['reorderPoint'] = 'نقطه سفارش مجدد نمی‌تواند منفی باشد'; + } + + if (formData.minOrderQty != null && formData.minOrderQty! < 0) { + errors['minOrderQty'] = 'کمینه مقدار سفارش نمی‌تواند منفی باشد'; + } + + if (formData.leadTimeDays != null) { + if (formData.leadTimeDays! < 0) { + errors['leadTimeDays'] = 'زمان تحویل نمی‌تواند منفی باشد'; + } else if (formData.leadTimeDays! > 365) { + errors['leadTimeDays'] = 'زمان تحویل نمی‌تواند بیشتر از ۳۶۵ روز باشد'; + } + } + + return errors; + } + + static bool isFormValid(ProductFormData formData) { + return validateFormData(formData).isEmpty; + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/category/category_tree_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/category/category_tree_dialog.dart new file mode 100644 index 0000000..1a6447e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/category/category_tree_dialog.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:flutter_simple_treeview/flutter_simple_treeview.dart'; +import '../../services/category_service.dart'; +import '../../core/api_client.dart'; +import '../../core/auth_store.dart'; + +class CategoryTreeDialog extends StatefulWidget { + final int businessId; + final AuthStore authStore; + + const CategoryTreeDialog({super.key, required this.businessId, required this.authStore}); + + @override + State createState() => _CategoryTreeDialogState(); +} + +class _CategoryTreeDialogState extends State { + late final CategoryService _service; + bool _loading = true; + String? _error; + List> _tree = const >[]; + // TreeController دیگر استفاده نمی‌شود + + @override + void initState() { + super.initState(); + _service = CategoryService(ApiClient()); + _fetch(); + } + + Future _fetch() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final items = await _service.getTree(businessId: widget.businessId); + setState(() { + _tree = items; + _loading = false; + }); + } catch (e) { + setState(() { + _loading = false; + _error = e.toString(); + }); + } + } + + + + bool get _canAdd => widget.authStore.hasBusinessPermission('categories', 'add'); + bool get _canEdit => widget.authStore.hasBusinessPermission('categories', 'edit'); + bool get _canDelete => widget.authStore.hasBusinessPermission('categories', 'delete'); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.9, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Header + Row( + children: [ + Icon( + Icons.category, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Text( + t.categoriesDialogTitle, + style: Theme.of(context).textTheme.headlineSmall, + ), + const Spacer(), + if (_canAdd) + FilledButton.icon( + onPressed: () => _showEditDialog(isRoot: true), + icon: const Icon(Icons.add), + label: Text(t.addRootCategory), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const Divider(), + const SizedBox(height: 16), + + // Content + Expanded(child: _buildBody(t)), + ], + ), + ), + ); + } + + + // تب‌ها حذف شده‌اند + + Widget _buildBody(AppLocalizations t) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Text(_error!)); + } + return RefreshIndicator( + onRefresh: _fetch, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + child: TreeView( + nodes: _buildTreeNodes(_tree, t), + indent: 24.0, + ), + ), + ), + ); + } + + List _buildTreeNodes(List> items, AppLocalizations t) { + return items.map((m) { + final id = m['id'] as int?; + final label = (m['label'] ?? m['title'] ?? m['name'] ?? '').toString(); + final children = (m['children'] as List?)?.cast() + .map((e) => Map.from(e as Map)) + .toList() ?? const >[]; + + final actions = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_canAdd) + IconButton( + tooltip: t.addChildCategory, + icon: const Icon(Icons.subdirectory_arrow_right), + onPressed: () => _showEditDialog(parentId: id), + ), + if (_canEdit) + IconButton( + tooltip: t.renameCategory, + icon: const Icon(Icons.edit), + onPressed: () => _showEditDialog(categoryId: id, initialLabel: label), + ), + if (_canDelete) + IconButton( + tooltip: t.deleteCategory, + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(id), + ), + ], + ); + + return TreeNode( + content: Row( + children: [ + Expanded(child: Text(label)), + actions, + ], + ), + children: _buildTreeNodes(children, t), + ); + }).toList(); + } + + // _buildNodeTile حذف شد؛ از TreeView استفاده می‌کنیم + + Future _confirmDelete(int? id) async { + if (id == null) return; + final t = AppLocalizations.of(context); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.deleteCategory), + content: Text(t.deleteCategoryConfirm), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t.delete)), + ], + ), + ); + if (ok != true) return; + await _service.delete(businessId: widget.businessId, categoryId: id); + await _fetch(); + } + + Future _showEditDialog({ + bool isRoot = false, + int? parentId, + int? categoryId, + String? initialLabel, + }) async { + final t = AppLocalizations.of(context); + final labelCtrl = TextEditingController(text: initialLabel ?? ''); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(categoryId == null ? t.createCategory : t.updateCategory), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: labelCtrl, decoration: const InputDecoration(labelText: 'Label')), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(categoryId == null ? t.createCategory : t.updateCategory), + ) + ], + ), + ); + if (ok != true) return; + if (categoryId == null) { + await _service.create( + businessId: widget.businessId, + parentId: isRoot ? null : parentId, + type: 'global', + label: labelCtrl.text.trim(), + ); + } else { + await _service.update( + businessId: widget.businessId, + categoryId: categoryId, + type: 'global', + label: labelCtrl.text.trim(), + ); + } + await _fetch(); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/README_REDESIGN.md b/hesabixUI/hesabix_ui/lib/widgets/product/README_REDESIGN.md new file mode 100644 index 0000000..1fd7e43 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/README_REDESIGN.md @@ -0,0 +1,163 @@ +# طراحی مجدد دیالوگ افزودن و ویرایش کالا + +## خلاصه تغییرات + +دیالوگ افزودن و ویرایش کالا به طور کامل بازطراحی شده تا ساختار بهتری داشته باشد و قابل نگهداری و توسعه باشد. + +## مشکلات قبلی + +### ساختار قبلی: +- **فایل تک‌تکه**: تمام منطق در یک فایل 550+ خطی +- **مدیریت وضعیت ضعیف**: بیش از 20 متغیر وضعیت جداگانه +- **عدم جداسازی مسئولیت‌ها**: UI، اعتبارسنجی، API و تبدیل داده همه در یک جا +- **عدم قابلیت استفاده مجدد**: بخش‌های فرم قابل استفاده مجدد نبودند +- **مدیریت خطای ضعیف**: try-catch ساده بدون پیام‌های خطای خاص +- **عدم ذخیره خودکار**: داده‌ها در صورت بسته شدن دیالوگ از دست می‌رفتند + +## راه‌حل جدید + +### 1. مدل داده (`ProductFormData`) +```dart +// فایل: lib/models/product_form_data.dart +class ProductFormData { + // تمام فیلدهای فرم در یک کلاس منظم + // متدهای copyWith، toPayload، fromProduct + // اعتبارسنجی داخلی +} +``` + +### 2. کنترلر فرم (`ProductFormController`) +```dart +// فایل: lib/controllers/product_form_controller.dart +class ProductFormController extends ChangeNotifier { + // مدیریت وضعیت متمرکز + // بارگذاری داده‌های مرجع + // اعتبارسنجی فرم + // ارسال داده‌ها +} +``` + +### 3. بخش‌های جداگانه فرم + +#### اطلاعات کلی (`ProductBasicInfoSection`) +- نوع کالا/خدمت +- کد و نام +- توضیحات +- دسته‌بندی +- ویژگی‌ها + +#### قیمت و موجودی (`ProductPricingInventorySection`) +- واحدها (اصلی، فرعی، ضریب تبدیل) +- کنترل موجودی +- قیمت‌گذاری +- تنظیمات سفارش + +#### مالیات (`ProductTaxSection`) +- مالیات فروش +- مالیات خرید +- کد مالیاتی + +### 4. اعتبارسنجی پیشرفته (`ProductFormValidator`) +```dart +// فایل: lib/utils/product_form_validator.dart +class ProductFormValidator { + static String? validateName(String? value) + static String? validatePrice(String? value) + static String? validateTaxRate(String? value) + // و سایر متدهای اعتبارسنجی +} +``` + +### 5. دیالوگ اصلی (`ProductFormDialog`) +```dart +// فایل: lib/widgets/product/product_form_dialog_v2.dart +class ProductFormDialog extends StatefulWidget { + // استفاده از TabBar برای سازماندهی بهتر + // مدیریت خطاهای بهتر + // UI بهبود یافته +} +``` + +## مزایای ساختار جدید + +### 1. **قابلیت نگهداری** +- هر بخش در فایل جداگانه +- کد تمیز و قابل فهم +- جداسازی مسئولیت‌ها + +### 2. **قابلیت استفاده مجدد** +- بخش‌های فرم قابل استفاده در جاهای دیگر +- کنترلر قابل استفاده برای فرم‌های مشابه + +### 3. **مدیریت وضعیت بهتر** +- تمام وضعیت در یک مکان +- تغییرات خودکار UI +- اعتبارسنجی متمرکز + +### 4. **تجربه کاربری بهتر** +- اعتبارسنجی لحظه‌ای +- پیام‌های خطای واضح +- UI سازمان‌یافته با تب‌ها + +### 5. **قابلیت توسعه** +- افزودن بخش‌های جدید آسان +- تغییرات مستقل در هر بخش +- تست‌پذیری بهتر + +## نحوه استفاده + +### افزودن کالای جدید: +```dart +showDialog( + context: context, + builder: (context) => ProductFormDialog( + businessId: businessId, + authStore: authStore, + onSuccess: () { + // کالا با موفقیت اضافه شد + }, + ), +); +``` + +### ویرایش کالای موجود: +```dart +showDialog( + context: context, + builder: (context) => ProductFormDialog( + businessId: businessId, + authStore: authStore, + product: existingProductData, + onSuccess: () { + // کالا با موفقیت به‌روزرسانی شد + }, + ), +); +``` + +## فایل‌های جدید + +1. `lib/models/product_form_data.dart` - مدل داده فرم +2. `lib/controllers/product_form_controller.dart` - کنترلر فرم +3. `lib/widgets/product/sections/product_basic_info_section.dart` - بخش اطلاعات کلی +4. `lib/widgets/product/sections/product_pricing_inventory_section.dart` - بخش قیمت و موجودی +5. `lib/widgets/product/sections/product_tax_section.dart` - بخش مالیات +6. `lib/widgets/product/product_form_dialog.dart` - دیالوگ اصلی جدید +7. `lib/utils/product_form_validator.dart` - اعتبارسنجی فرم +8. `lib/examples/product_management_example.dart` - مثال استفاده + +## مهاجرت از نسخه قدیمی + +برای استفاده از نسخه جدید، کافی است: + +1. فایل `product_form_dialog.dart` قدیمی با نسخه جدید جایگزین شده است +2. import ها به‌روزرسانی شده‌اند +3. از کنترلر جدید استفاده می‌شود + +## ویژگی‌های آینده + +- [ ] ذخیره خودکار فرم +- [ ] اعتبارسنجی سرور +- [ ] پشتیبانی از تصاویر کالا +- [ ] تاریخچه تغییرات +- [ ] قالب‌های پیش‌فرض diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart new file mode 100644 index 0000000..0b450e8 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../core/auth_store.dart'; +import '../../controllers/product_form_controller.dart'; +import 'sections/product_basic_info_section.dart'; +import 'sections/product_pricing_inventory_section.dart'; +import 'sections/product_tax_section.dart'; + +class ProductFormDialog extends StatefulWidget { + final int businessId; + final Map? product; + final VoidCallback? onSuccess; + final AuthStore authStore; + + const ProductFormDialog({ + super.key, + required this.businessId, + required this.authStore, + this.product, + this.onSuccess, + }); + + @override + State createState() => _ProductFormDialogState(); +} + +class _ProductFormDialogState extends State { + final _formKey = GlobalKey(); + late final ProductFormController _controller; + + @override + void initState() { + super.initState(); + _controller = ProductFormController(businessId: widget.businessId); + _initializeForm(); + } + + Future _initializeForm() async { + await _controller.initializeWithProduct(widget.product); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + return ListenableBuilder( + listenable: _controller, + builder: (context, child) { + return AlertDialog( + title: Row( + children: [ + Icon(widget.product == null ? Icons.add : Icons.edit), + const SizedBox(width: 8), + Text(widget.product == null ? t.addProduct : t.edit), + ], + ), + content: SizedBox( + width: MediaQuery.of(context).size.width > 1200 ? 1000 : 800, + child: _controller.isLoading + ? _buildLoadingWidget() + : _buildFormContent(), + ), + actions: _buildActions(t), + ); + }, + ); + } + + Widget _buildLoadingWidget() { + return const SizedBox( + height: 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('در حال بارگذاری...'), + ], + ), + ), + ); + } + + Widget _buildFormContent() { + return DefaultTabController( + length: 3, + child: SizedBox( + height: MediaQuery.of(context).size.height > 800 ? 700 : 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTabBar(), + const SizedBox(height: 12), + Expanded( + child: Form( + key: _formKey, + child: TabBarView( + children: [ + _buildBasicInfoTab(), + _buildPricingInventoryTab(), + _buildTaxTab(), + ], + ), + ), + ), + if (_controller.errorMessage != null) _buildErrorMessage(), + ], + ), + ), + ); + } + + Widget _buildTabBar() { + return TabBar( + isScrollable: true, + tabs: const [ + Tab(text: 'اطلاعات کلی'), + Tab(text: 'قیمت و موجودی'), + Tab(text: 'مالیات'), + ], + ); + } + + Widget _buildBasicInfoTab() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: ProductBasicInfoSection( + formData: _controller.formData, + onChanged: _controller.updateFormData, + categories: _controller.categories, + attributes: _controller.attributes, + units: _controller.units, + ), + ); + } + + Widget _buildPricingInventoryTab() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: ProductPricingInventorySection( + formData: _controller.formData, + onChanged: _controller.updateFormData, + units: const [], + ), + ); + } + + Widget _buildTaxTab() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: ProductTaxSection( + formData: _controller.formData, + onChanged: _controller.updateFormData, + taxTypes: _controller.taxTypes, + taxUnits: _controller.taxUnits, + ), + ); + } + + Widget _buildErrorMessage() { + return Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + border: Border.all(color: Colors.red.shade200), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: Colors.red.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + _controller.errorMessage!, + style: TextStyle(color: Colors.red.shade700), + ), + ), + ], + ), + ); + } + + List _buildActions(AppLocalizations t) { + return [ + TextButton( + onPressed: _controller.isLoading ? null : () => Navigator.of(context).pop(), + child: Text(t.cancel), + ), + FilledButton( + onPressed: _controller.isLoading ? null : _handleSubmit, + child: _controller.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.save), + ), + ]; + } + + Future _handleSubmit() async { + if (!_controller.validateForm(_formKey)) { + return; + } + + bool success; + if (widget.product != null) { + final productId = widget.product!['id'] as int; + success = await _controller.updateProduct(productId); + } else { + success = await _controller.submitForm(); + } + + if (success && mounted) { + widget.onSuccess?.call(); + Navigator.of(context).pop(true); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_controller.errorMessage ?? 'خطای نامشخص'), + backgroundColor: Colors.red, + action: SnackBarAction( + label: 'تلاش مجدد', + textColor: Colors.white, + onPressed: _handleSubmit, + ), + ), + ); + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart new file mode 100644 index 0000000..8e85e85 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart @@ -0,0 +1,356 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:flutter/services.dart'; +import '../../../models/product_form_data.dart'; +import '../../../utils/product_form_validator.dart'; + +class ProductBasicInfoSection extends StatelessWidget { + final ProductFormData formData; + final ValueChanged onChanged; + final List> categories; + final List> attributes; + final List> units; + + const ProductBasicInfoSection({ + super.key, + required this.formData, + required this.onChanged, + required this.categories, + required this.attributes, + required this.units, + }); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final isWideScreen = MediaQuery.of(context).size.width > 1000; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isWideScreen) ...[ + // دو ستون برای صفحه‌های بزرگ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + _buildItemTypeSelector(context), + const SizedBox(height: 20), + + TextFormField( + initialValue: formData.code, + decoration: InputDecoration(labelText: t.code + ' (اختیاری)'), + validator: ProductFormValidator.validateCode, + onChanged: (value) => _updateFormData(formData.copyWith(code: value.trim().isEmpty ? null : value.trim())), + ), + const SizedBox(height: 20), + + TextFormField( + initialValue: formData.name, + decoration: InputDecoration(labelText: t.title), + validator: ProductFormValidator.validateName, + onChanged: (value) => _updateFormData(formData.copyWith(name: value)), + ), + const SizedBox(height: 20), + _buildUnitsSection(context), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + children: [ + TextFormField( + initialValue: formData.description, + decoration: InputDecoration(labelText: t.description), + onChanged: (value) => _updateFormData(formData.copyWith(description: value.trim().isEmpty ? null : value)), + maxLines: 4, + ), + const SizedBox(height: 20), + + DropdownButtonFormField( + value: formData.categoryId, + items: categories + .map((category) => DropdownMenuItem( + value: category['id'] as int, + child: Text((category['label'] ?? '').toString()), + )) + .toList(), + onChanged: (value) => _updateFormData(formData.copyWith(categoryId: value)), + decoration: InputDecoration(labelText: t.categories), + ), + ], + ), + ), + ], + ), + ] else ...[ + // یک ستون برای صفحه‌های کوچک + _buildItemTypeSelector(context), + const SizedBox(height: 20), + + TextFormField( + initialValue: formData.code, + decoration: InputDecoration(labelText: t.code + ' (اختیاری)'), + validator: ProductFormValidator.validateCode, + onChanged: (value) => _updateFormData(formData.copyWith(code: value.trim().isEmpty ? null : value.trim())), + ), + const SizedBox(height: 20), + + TextFormField( + initialValue: formData.name, + decoration: InputDecoration(labelText: t.title), + validator: ProductFormValidator.validateName, + onChanged: (value) => _updateFormData(formData.copyWith(name: value)), + ), + const SizedBox(height: 20), + + TextFormField( + initialValue: formData.description, + decoration: InputDecoration(labelText: t.description), + onChanged: (value) => _updateFormData(formData.copyWith(description: value.trim().isEmpty ? null : value)), + maxLines: 3, + ), + const SizedBox(height: 20), + + DropdownButtonFormField( + value: formData.categoryId, + items: categories + .map((category) => DropdownMenuItem( + value: category['id'] as int, + child: Text((category['label'] ?? '').toString()), + )) + .toList(), + onChanged: (value) => _updateFormData(formData.copyWith(categoryId: value)), + decoration: InputDecoration(labelText: t.categories), + ), + const SizedBox(height: 20), + _buildUnitsSection(context), + ], + + if (attributes.isNotEmpty) ...[ + const SizedBox(height: 32), + Text('ویژگی‌ها', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: attributes.map((attr) { + final id = (attr['id'] as num).toInt(); + final title = (attr['title'] ?? 'ویژگی ${attr['id']}').toString(); + final selected = formData.selectedAttributeIds.contains(id); + return FilterChip( + label: Text(title), + selected: selected, + onSelected: (value) { + final newIds = Set.from(formData.selectedAttributeIds); + if (value) { + newIds.add(id); + } else { + newIds.remove(id); + } + _updateFormData(formData.copyWith(selectedAttributeIds: newIds)); + }, + ); + }).toList(), + ), + ], + ], + ); + } + + void _updateFormData(ProductFormData newData) { + onChanged(newData); + } + + Widget _buildUnitsSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('واحدها', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: _buildUnitTextField( + label: 'واحد اصلی', + isRequired: true, + initialText: _unitNameById(formData.mainUnitId) ?? 'عدد', + onChanged: (text) { + final mappedId = _findUnitIdByTitle(text); + _updateFormData(formData.copyWith(mainUnitId: mappedId)); + }, + )), + const SizedBox(width: 12), + Expanded(child: _buildUnitTextField( + label: 'واحد فرعی', + isRequired: false, + initialText: _unitNameById(formData.secondaryUnitId) ?? '', + onChanged: (text) { + final mappedId = _findUnitIdByTitle(text); + _updateFormData(formData.copyWith(secondaryUnitId: mappedId)); + }, + )), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: formData.unitConversionFactor?.toString(), + decoration: const InputDecoration(labelText: 'ضریب تبدیل واحد'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\\d*\\.?\\d{0,2}$')), + ], + validator: ProductFormValidator.validateConversionFactor, + onChanged: (value) => _updateFormData(formData.copyWith(unitConversionFactor: num.tryParse(value))), + ), + ), + ], + ), + ], + ); + } + + Widget _buildUnitTextField({ + required String label, + required bool isRequired, + required String initialText, + required ValueChanged onChanged, + }) { + return TextFormField( + initialValue: initialText, + decoration: InputDecoration(labelText: label), + keyboardType: TextInputType.text, + validator: isRequired ? (v) => (v == null || v.trim().isEmpty) ? '$label الزامی است' : null : null, + onChanged: onChanged, + ); + } + + String? _unitNameById(int? id) { + if (id == null) return null; + try { + final u = units.firstWhere((e) => (e['id'] as num).toInt() == id); + return (u['title'] ?? u['name'])?.toString(); + } catch (_) { + return null; + } + } + + int? _findUnitIdByTitle(String? title) { + if (title == null) return null; + final t = title.trim(); + if (t.isEmpty) return null; + for (final u in units) { + final name = (u['title'] ?? u['name'] ?? '').toString(); + if (name.trim().toLowerCase() == t.toLowerCase()) { + return (u['id'] as num).toInt(); + } + } + return null; + } + + Widget _buildItemTypeSelector(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'نوع', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildItemTypeCard( + context: context, + title: 'کالا', + subtitle: 'محصولات فیزیکی', + icon: Icons.inventory_2_outlined, + value: 'کالا', + isSelected: formData.itemType == 'کالا', + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildItemTypeCard( + context: context, + title: 'خدمت', + subtitle: 'خدمات و سرویس‌ها', + icon: Icons.handyman_outlined, + value: 'خدمت', + isSelected: formData.itemType == 'خدمت', + ), + ), + ], + ), + ], + ); + } + + Widget _buildItemTypeCard({ + required BuildContext context, + required String title, + required String subtitle, + required IconData icon, + required String value, + required bool isSelected, + }) { + return InkWell( + onTap: () => _updateFormData(formData.copyWith(itemType: value)), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? Theme.of(context).primaryColor + : Theme.of(context).dividerColor, + width: isSelected ? 2 : 1, + ), + color: isSelected + ? Theme.of(context).primaryColor.withOpacity(0.05) + : Theme.of(context).cardColor, + ), + child: Column( + children: [ + Icon( + icon, + size: 32, + color: isSelected + ? Theme.of(context).primaryColor + : Theme.of(context).iconTheme.color, + ), + const SizedBox(height: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isSelected + ? Theme.of(context).primaryColor + : null, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Radio( + value: value, + groupValue: formData.itemType, + onChanged: (val) => _updateFormData(formData.copyWith(itemType: val ?? 'کالا')), + activeColor: Theme.of(context).primaryColor, + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart new file mode 100644 index 0000000..f0511a6 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../../models/product_form_data.dart'; +import '../../../utils/product_form_validator.dart'; + +class ProductPricingInventorySection extends StatelessWidget { + final ProductFormData formData; + final ValueChanged onChanged; + final List> units; + + const ProductPricingInventorySection({ + super.key, + required this.formData, + required this.onChanged, + required this.units, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInventorySection(), + const SizedBox(height: 24), + _buildPricingSection(context), + ], + ); + } + + + Widget _buildInventorySection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + value: formData.trackInventory, + onChanged: (value) => _updateFormData(formData.copyWith(trackInventory: value)), + title: const Text('کنترل موجودی'), + ), + if (formData.trackInventory) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: formData.reorderPoint?.toString(), + decoration: const InputDecoration(labelText: 'نقطه سفارش مجدد'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: (value) => ProductFormValidator.validateQuantity(value, fieldName: 'نقطه سفارش مجدد'), + onChanged: (value) => _updateFormData(formData.copyWith(reorderPoint: int.tryParse(value))), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: formData.minOrderQty?.toString(), + decoration: const InputDecoration(labelText: 'کمینه مقدار سفارش'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: (value) => ProductFormValidator.validateQuantity(value, fieldName: 'کمینه مقدار سفارش'), + onChanged: (value) => _updateFormData(formData.copyWith(minOrderQty: int.tryParse(value))), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: formData.leadTimeDays?.toString(), + decoration: const InputDecoration(labelText: 'زمان تحویل (روز)'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: ProductFormValidator.validateLeadTime, + onChanged: (value) => _updateFormData(formData.copyWith(leadTimeDays: int.tryParse(value))), + ), + ), + ], + ), + ], + ], + ); + } + + Widget _buildPricingSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('قیمت‌گذاری', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + TextFormField( + initialValue: formData.baseSalesPrice?.toString(), + decoration: const InputDecoration(labelText: 'قیمت فروش'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), + ], + validator: (value) => ProductFormValidator.validatePrice(value, fieldName: 'قیمت فروش'), + onChanged: (value) => _updateFormData(formData.copyWith(baseSalesPrice: num.tryParse(value))), + ), + const SizedBox(height: 16), + TextFormField( + initialValue: formData.baseSalesNote, + decoration: const InputDecoration(labelText: 'توضیح قیمت فروش'), + maxLines: 2, + onChanged: (value) => _updateFormData(formData.copyWith(baseSalesNote: value.trim().isEmpty ? null : value)), + ), + const SizedBox(height: 16), + TextFormField( + initialValue: formData.basePurchasePrice?.toString(), + decoration: const InputDecoration(labelText: 'قیمت خرید'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), + ], + validator: (value) => ProductFormValidator.validatePrice(value, fieldName: 'قیمت خرید'), + onChanged: (value) => _updateFormData(formData.copyWith(basePurchasePrice: num.tryParse(value))), + ), + const SizedBox(height: 16), + TextFormField( + initialValue: formData.basePurchaseNote, + decoration: const InputDecoration(labelText: 'توضیح قیمت خرید'), + maxLines: 2, + onChanged: (value) => _updateFormData(formData.copyWith(basePurchaseNote: value.trim().isEmpty ? null : value)), + ), + ], + ); + } + + void _updateFormData(ProductFormData newData) { + onChanged(newData); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart new file mode 100644 index 0000000..00fb941 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../../models/product_form_data.dart'; +import '../../../utils/product_form_validator.dart'; + +class ProductTaxSection extends StatelessWidget { + final ProductFormData formData; + final ValueChanged onChanged; + final List> taxTypes; + final List> taxUnits; + + const ProductTaxSection({ + super.key, + required this.formData, + required this.onChanged, + required this.taxTypes, + required this.taxUnits, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('مالیات', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 16), + _buildTaxCodeTypeUnitRow(context), + const SizedBox(height: 24), + _buildSalesTaxSection(), + const SizedBox(height: 24), + _buildPurchaseTaxSection(), + ], + ); + } + + Widget _buildTaxCodeTypeUnitRow(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final bool isDesktop = constraints.maxWidth >= 1000; + if (isDesktop) { + return Row( + children: [ + Expanded(child: _buildTaxCodeField()), + const SizedBox(width: 12), + Expanded(child: _buildTaxTypeDropdown()), + const SizedBox(width: 12), + Expanded(child: _buildTaxUnitDropdown()), + ], + ); + } + return Column( + children: [ + _buildTaxCodeField(), + const SizedBox(height: 12), + _buildTaxTypeDropdown(), + const SizedBox(height: 12), + _buildTaxUnitDropdown(), + ], + ); + }, + ); + } + + Widget _buildTaxCodeField() { + return TextFormField( + initialValue: formData.taxCode, + decoration: const InputDecoration(labelText: 'کُد مالیاتی'), + onChanged: (value) => _updateFormData( + formData.copyWith(taxCode: value.trim().isEmpty ? null : value), + ), + ); + } + + Widget _buildSalesTaxSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + value: formData.isSalesTaxable, + onChanged: (value) => _updateFormData(formData.copyWith(isSalesTaxable: value)), + title: const Text('مشمول مالیات فروش'), + ), + if (formData.isSalesTaxable) ...[ + const SizedBox(height: 16), + TextFormField( + initialValue: formData.salesTaxRate?.toString(), + decoration: const InputDecoration(labelText: 'نرخ مالیات فروش (%)'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: 'نرخ مالیات فروش'), + onChanged: (value) => _updateFormData(formData.copyWith(salesTaxRate: num.tryParse(value))), + ), + ], + ], + ); + } + + Widget _buildPurchaseTaxSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + value: formData.isPurchaseTaxable, + onChanged: (value) => _updateFormData(formData.copyWith(isPurchaseTaxable: value)), + title: const Text('مشمول مالیات خرید'), + ), + if (formData.isPurchaseTaxable) ...[ + const SizedBox(height: 16), + TextFormField( + initialValue: formData.purchaseTaxRate?.toString(), + decoration: const InputDecoration(labelText: 'نرخ مالیات خرید (%)'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: 'نرخ مالیات خرید'), + onChanged: (value) => _updateFormData(formData.copyWith(purchaseTaxRate: num.tryParse(value))), + ), + ], + ], + ); + } + + Widget _buildTaxTypeDropdown() { + if (taxTypes.isNotEmpty) { + return DropdownButtonFormField( + value: formData.taxTypeId, + items: taxTypes + .map((taxType) => DropdownMenuItem( + value: (taxType['id'] as num).toInt(), + child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()), + )) + .toList(), + onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: value)), + decoration: const InputDecoration(labelText: 'نوع مالیات'), + ); + } else { + return TextFormField( + initialValue: formData.taxTypeId?.toString(), + decoration: const InputDecoration(labelText: 'شناسه نوع مالیات'), + keyboardType: TextInputType.number, + onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: int.tryParse(value))), + ); + } + } + + Widget _buildTaxUnitDropdown() { + final List> effectiveTaxUnits = taxUnits.isNotEmpty ? taxUnits : _fallbackTaxUnits(); + if (effectiveTaxUnits.isNotEmpty) { + return DropdownButtonFormField( + value: formData.taxUnitId, + items: effectiveTaxUnits + .map((taxUnit) => DropdownMenuItem( + value: (taxUnit['id'] as num).toInt(), + child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()), + )) + .toList(), + onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: value)), + decoration: const InputDecoration(labelText: 'واحد مالیاتی'), + ); + } else { + return TextFormField( + initialValue: formData.taxUnitId?.toString(), + decoration: const InputDecoration(labelText: 'شناسه واحد مالیاتی'), + keyboardType: TextInputType.number, + onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: int.tryParse(value))), + ); + } + } + + List> _fallbackTaxUnits() { + final titles = [ + 'بانكه', 'برگ', 'بسته', 'بشكه', 'بطری', 'بندیل', 'پاکت', 'پالت', + 'تانكر', 'تخته', 'تن', 'تن کیلومتر', 'توپ', 'تیوب', 'ثانیه', 'ثوب', + 'جام', 'جعبه', 'جفت', 'جلد', 'چلیك', 'حلب', 'حلقه (رول)', 'حلقه (دیسک)', + 'حلقه (رینگ)', 'دبه', 'دست', 'دستگاه', 'دقیقه', 'دوجین', 'روز', 'رول', + 'ساشه', 'ساعت', 'سال', 'سانتی متر', 'سانتی متر مربع', 'سبد', 'ست', 'سطل', + 'سیلندر', 'شاخه', 'شانه', 'شعله', 'شیت', 'صفحه', 'طاقه', 'طغرا', 'عدد', + 'عدل', 'فاقد بسته بندی', 'فروند', 'فوت مربع', 'قالب', 'قراص', 'قراصه (bundle)', + 'قرقره', 'قطعه', 'قوطي', 'قیراط', 'کارتن', 'کارتن (master case)', 'کلاف', 'کپسول', + 'کیسه', 'کیلوگرم', 'کیلومتر', 'کیلووات ساعت', 'گالن', 'گرم', 'گیگابایت بر ثانیه', + 'لنگه', 'لیتر', 'لیوان', 'ماه', 'متر', 'متر مربع', 'متر مكعب', 'مخزن', + 'مگاوات ساعت', 'ميلي گرم', 'ميلي لیتر', 'ميلي متر', 'نخ', 'نسخه (جلد)', + 'نفر', 'نفر- ساعت', 'نوبت', 'نیم دوجین', 'واحد', 'ورق', 'ویال' + ]; + // Generate predictable ids starting from 1 + return List.generate(titles.length, (i) => { + 'id': i + 1, + 'title': titles[i], + }); + } + + void _updateFormData(ProductFormData newData) { + onChanged(newData); + } +} diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index b4a1b55..fcec841 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -214,6 +214,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_fancy_tree_view: + dependency: "direct main" + description: + name: flutter_fancy_tree_view + sha256: e8ef261170be1d63fe56843baa2b501fc353196eadef51d848e882d8cbe10c31 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" flutter_lints: dependency: "direct dev" description: @@ -283,6 +291,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.1.2" + flutter_simple_treeview: + dependency: "direct main" + description: + name: flutter_simple_treeview + sha256: ad4978d2668dd078d3a09966832da111bef9102dd636e572c50c80133b7ff4d9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" flutter_test: dependency: "direct dev" description: flutter diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index 1c4d92b..a5baff0 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -28,6 +28,8 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + flutter_simple_treeview: ^3.0.2 + flutter_fancy_tree_view: ^1.6.0 flutter: sdk: flutter flutter_localizations: diff --git a/hesabixUI/hesabix_ui/test/product_form_test.dart b/hesabixUI/hesabix_ui/test/product_form_test.dart new file mode 100644 index 0000000..541b364 --- /dev/null +++ b/hesabixUI/hesabix_ui/test/product_form_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hesabix_ui/models/product_form_data.dart'; +import 'package:hesabix_ui/utils/product_form_validator.dart'; + +void main() { + group('ProductFormData Tests', () { + test('should create default instance', () { + final formData = ProductFormData(); + + expect(formData.itemType, 'کالا'); + expect(formData.name, ''); + expect(formData.trackInventory, false); + expect(formData.isSalesTaxable, false); + expect(formData.isPurchaseTaxable, false); + expect(formData.selectedAttributeIds, isEmpty); + }); + + test('should create from product data', () { + final productData = { + 'id': 1, + 'name': 'کالای تست', + 'code': 'TEST001', + 'item_type': 'خدمت', + 'base_sales_price': 100000, + 'track_inventory': true, + 'is_sales_taxable': true, + 'sales_tax_rate': 9.0, + }; + + final formData = ProductFormData.fromProduct(productData); + + expect(formData.name, 'کالای تست'); + expect(formData.code, 'TEST001'); + expect(formData.itemType, 'خدمت'); + expect(formData.baseSalesPrice, 100000); + expect(formData.trackInventory, true); + expect(formData.isSalesTaxable, true); + expect(formData.salesTaxRate, 9.0); + }); + + test('should copy with new values', () { + final original = ProductFormData(); + final updated = original.copyWith( + name: 'کالای جدید', + baseSalesPrice: 50000, + ); + + expect(updated.name, 'کالای جدید'); + expect(updated.baseSalesPrice, 50000); + expect(updated.itemType, original.itemType); // unchanged + }); + + test('should convert to payload', () { + final formData = ProductFormData( + name: 'کالای تست', + code: 'TEST001', + baseSalesPrice: 100000, + trackInventory: true, + ); + + final payload = formData.toPayload(); + + expect(payload['name'], 'کالای تست'); + expect(payload['code'], 'TEST001'); + expect(payload['base_sales_price'], 100000); + expect(payload['track_inventory'], true); + expect(payload.containsKey('description'), false); // null values removed + }); + }); + + group('ProductFormValidator Tests', () { + test('should validate name correctly', () { + expect(ProductFormValidator.validateName(null), 'نام کالا الزامی است'); + expect(ProductFormValidator.validateName(''), 'نام کالا الزامی است'); + expect(ProductFormValidator.validateName(' '), 'نام کالا الزامی است'); + expect(ProductFormValidator.validateName('ک'), 'نام کالا باید حداقل ۲ کاراکتر باشد'); + expect(ProductFormValidator.validateName('کالا'), null); + }); + + test('should validate price correctly', () { + expect(ProductFormValidator.validatePrice(''), null); + expect(ProductFormValidator.validatePrice('100'), null); + expect(ProductFormValidator.validatePrice('100.50'), null); + expect(ProductFormValidator.validatePrice('abc'), 'قیمت باید عدد معتبر باشد'); + expect(ProductFormValidator.validatePrice('-10'), 'قیمت نمی‌تواند منفی باشد'); + }); + + test('should validate tax rate correctly', () { + expect(ProductFormValidator.validateTaxRate(''), null); + expect(ProductFormValidator.validateTaxRate('9'), null); + expect(ProductFormValidator.validateTaxRate('9.5'), null); + expect(ProductFormValidator.validateTaxRate('100'), null); + expect(ProductFormValidator.validateTaxRate('abc'), 'نرخ مالیات باید عدد معتبر باشد'); + expect(ProductFormValidator.validateTaxRate('-5'), 'نرخ مالیات نمی‌تواند منفی باشد'); + expect(ProductFormValidator.validateTaxRate('101'), 'نرخ مالیات نمی‌تواند بیشتر از ۱۰۰٪ باشد'); + }); + + test('should validate conversion factor correctly', () { + expect(ProductFormValidator.validateConversionFactor(''), null); + expect(ProductFormValidator.validateConversionFactor('2'), null); + expect(ProductFormValidator.validateConversionFactor('2.5'), null); + expect(ProductFormValidator.validateConversionFactor('abc'), 'ضریب تبدیل باید عدد معتبر باشد'); + expect(ProductFormValidator.validateConversionFactor('0'), 'ضریب تبدیل باید بزرگتر از صفر باشد'); + expect(ProductFormValidator.validateConversionFactor('-1'), 'ضریب تبدیل باید بزرگتر از صفر باشد'); + }); + + test('should validate lead time correctly', () { + expect(ProductFormValidator.validateLeadTime(''), null); + expect(ProductFormValidator.validateLeadTime('7'), null); + expect(ProductFormValidator.validateLeadTime('365'), null); + expect(ProductFormValidator.validateLeadTime('abc'), 'زمان تحویل باید عدد صحیح باشد'); + expect(ProductFormValidator.validateLeadTime('-1'), 'زمان تحویل نمی‌تواند منفی باشد'); + expect(ProductFormValidator.validateLeadTime('366'), 'زمان تحویل نمی‌تواند بیشتر از ۳۶۵ روز باشد'); + }); + + test('should validate form data correctly', () { + final validData = ProductFormData( + name: 'کالای معتبر', + baseSalesPrice: 100000, + salesTaxRate: 9, + unitConversionFactor: 2, + ); + + final invalidData = ProductFormData( + name: '', // invalid + baseSalesPrice: -100, // invalid + salesTaxRate: 101, // invalid + unitConversionFactor: 0, // invalid + ); + + expect(ProductFormValidator.isFormValid(validData), true); + expect(ProductFormValidator.isFormValid(invalidData), false); + + final errors = ProductFormValidator.validateFormData(invalidData); + expect(errors.containsKey('name'), true); + expect(errors.containsKey('baseSalesPrice'), true); + expect(errors.containsKey('salesTaxRate'), true); + expect(errors.containsKey('unitConversionFactor'), true); + }); + }); +}