progress in commodity
This commit is contained in:
parent
a371499e31
commit
6b908eea5d
|
|
@ -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
|
||||
|
||||
148
hesabixAPI/adapters/api/v1/categories.py
Normal file
148
hesabixAPI/adapters/api/v1/categories.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
163
hesabixAPI/adapters/api/v1/price_lists.py
Normal file
163
hesabixAPI/adapters/api/v1/price_lists.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
124
hesabixAPI/adapters/api/v1/product_attributes.py
Normal file
124
hesabixAPI/adapters/api/v1/product_attributes.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
300
hesabixAPI/adapters/api/v1/products.py
Normal file
300
hesabixAPI/adapters/api/v1/products.py
Normal file
|
|
@ -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 = """
|
||||
<style>
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #777; padding: 6px; font-size: 12px; }
|
||||
th { background: #eee; }
|
||||
h1 { font-size: 16px; }
|
||||
.meta { font-size: 12px; color: #666; margin-bottom: 10px; }
|
||||
</style>
|
||||
"""
|
||||
title = "گزارش فهرست محصولات"
|
||||
now = datetime.datetime.utcnow().isoformat()
|
||||
header_row = "".join([f"<th>{h}</th>" for h in headers])
|
||||
body_rows = "".join([
|
||||
"<tr>" + "".join([f"<td>{(it.get(k) if it.get(k) is not None else '')}</td>" for k in keys]) + "</tr>"
|
||||
for it in items
|
||||
])
|
||||
html = f"""
|
||||
<html><head>{head_html}</head><body>
|
||||
<h1>{title}</h1>
|
||||
<div class=meta>زمان تولید: {now}</div>
|
||||
<table>
|
||||
<thead><tr>{header_row}</tr></thead>
|
||||
<tbody>{body_rows}</tbody>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
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)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
60
hesabixAPI/adapters/api/v1/schema_models/price_list.py
Normal file
60
hesabixAPI/adapters/api/v1/schema_models/price_list.py
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
110
hesabixAPI/adapters/api/v1/schema_models/product.py
Normal file
110
hesabixAPI/adapters/api/v1/schema_models/product.py
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
49
hesabixAPI/adapters/api/v1/tax_types.py
Normal file
49
hesabixAPI/adapters/api/v1/tax_types.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
382
hesabixAPI/adapters/api/v1/tax_units.py
Normal file
382
hesabixAPI/adapters/api/v1/tax_units.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
32
hesabixAPI/adapters/db/models/category.py
Normal file
32
hesabixAPI/adapters/db/models/category.py
Normal file
|
|
@ -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")
|
||||
|
||||
|
||||
53
hesabixAPI/adapters/db/models/price_list.py
Normal file
53
hesabixAPI/adapters/db/models/price_list.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
87
hesabixAPI/adapters/db/models/product.py
Normal file
87
hesabixAPI/adapters/db/models/product.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
30
hesabixAPI/adapters/db/models/product_attribute.py
Normal file
30
hesabixAPI/adapters/db/models/product_attribute.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
24
hesabixAPI/adapters/db/models/product_attribute_link.py
Normal file
24
hesabixAPI/adapters/db/models/product_attribute_link.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
24
hesabixAPI/adapters/db/models/tax_unit.py
Normal file
24
hesabixAPI/adapters/db/models/tax_unit.py
Normal file
|
|
@ -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)
|
||||
92
hesabixAPI/adapters/db/repositories/category_repository.py
Normal file
92
hesabixAPI/adapters/db/repositories/category_repository.py
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
156
hesabixAPI/adapters/db/repositories/price_list_repository.py
Normal file
156
hesabixAPI/adapters/db/repositories/price_list_repository.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
111
hesabixAPI/adapters/db/repositories/product_repository.py
Normal file
111
hesabixAPI/adapters/db/repositories/product_repository.py
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
143
hesabixAPI/app/services/price_list_service.py
Normal file
143
hesabixAPI/app/services/price_list_service.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
109
hesabixAPI/app/services/product_attribute_service.py
Normal file
109
hesabixAPI/app/services/product_attribute_service.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
223
hesabixAPI/app/services/product_service.py
Normal file
223
hesabixAPI/app/services/product_service.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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<Map<String, dynamic>> _categories = [];
|
||||
List<Map<String, dynamic>> _attributes = [];
|
||||
List<Map<String, dynamic>> _units = [];
|
||||
List<Map<String, dynamic>> _taxTypes = [];
|
||||
List<Map<String, dynamic>> _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<Map<String, dynamic>> get categories => _categories;
|
||||
List<Map<String, dynamic>> get attributes => _attributes;
|
||||
List<Map<String, dynamic>> get units => _units;
|
||||
List<Map<String, dynamic>> get taxTypes => _taxTypes;
|
||||
List<Map<String, dynamic>> get taxUnits => _taxUnits;
|
||||
|
||||
// Initialize form with existing product data
|
||||
Future<void> initializeWithProduct(Map<String, dynamic>? 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<void> _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<Map<String, dynamic>>.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<FormState> formKey) {
|
||||
return formKey.currentState?.validate() ?? false;
|
||||
}
|
||||
|
||||
// Submit form
|
||||
Future<bool> 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<bool> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>? _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<String, dynamic>? get businessPermissions => _businessPermissions;
|
||||
String? get selectedCurrencyCode => _selectedCurrencyCode;
|
||||
int? get selectedCurrencyId => _selectedCurrencyId;
|
||||
|
||||
Future<void> 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<void> 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<void> _loadSelectedCurrency() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final code = prefs.getString(_kSelectedCurrencyCode);
|
||||
final id = prefs.getInt(_kSelectedCurrencyId);
|
||||
_selectedCurrencyCode = code;
|
||||
_selectedCurrencyId = id;
|
||||
}
|
||||
|
||||
Future<void> 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<void> clearSelectedCurrency() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_selectedCurrencyCode = null;
|
||||
_selectedCurrencyId = null;
|
||||
await prefs.remove(_kSelectedCurrencyCode);
|
||||
await prefs.remove(_kSelectedCurrencyId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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('حذف'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "عملیات ناموفق بود"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => 'عملیات ناموفق بود';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> permissions;
|
||||
final CurrencyLite? defaultCurrency;
|
||||
final List<CurrencyLite> 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 <CurrencyLite>[],
|
||||
});
|
||||
|
||||
factory BusinessWithPermission.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -240,6 +267,12 @@ class BusinessWithPermission {
|
|||
isOwner: json['is_owner'] ?? false,
|
||||
role: json['role'] ?? 'عضو',
|
||||
permissions: Map<String, dynamic>.from(json['permissions'] ?? {}),
|
||||
defaultCurrency: json['default_currency'] != null
|
||||
? CurrencyLite.fromJson(Map<String, dynamic>.from(json['default_currency']))
|
||||
: null,
|
||||
currencies: (json['currencies'] as List<dynamic>? ?? const [])
|
||||
.map((c) => CurrencyLite.fromJson(Map<String, dynamic>.from(c)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
hesabixUI/hesabix_ui/lib/models/product_form_data.dart
Normal file
196
hesabixUI/hesabix_ui/lib/models/product_form_data.dart
Normal file
|
|
@ -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<int> 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<int>? selectedAttributeIds,
|
||||
}) : selectedAttributeIds = selectedAttributeIds ?? <int>{};
|
||||
|
||||
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<int>? 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<String, dynamic> 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<String, dynamic> 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<int> _parseAttributeIds(dynamic value) {
|
||||
if (value is List) {
|
||||
return value.whereType<int>().toSet();
|
||||
}
|
||||
return <int>{};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BusinessShell> {
|
|||
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<BusinessShell> {
|
|||
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<BusinessShell> {
|
|||
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<bool>(
|
||||
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<bool>(
|
||||
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<BusinessShell> {
|
|||
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<bool>(
|
||||
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<BusinessShell> {
|
|||
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<BusinessShell> {
|
|||
// 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<BusinessShell> {
|
|||
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';
|
||||
|
|
|
|||
|
|
@ -130,11 +130,6 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
width: ColumnWidth.medium,
|
||||
formatter: (person) => person.nationalId ?? '-',
|
||||
),
|
||||
DateColumn(
|
||||
'created_at',
|
||||
t.createdAt,
|
||||
width: ColumnWidth.medium,
|
||||
),
|
||||
NumberColumn(
|
||||
'share_count',
|
||||
t.shareCount,
|
||||
|
|
|
|||
|
|
@ -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<PriceListItemsPage> createState() => _PriceListItemsPageState();
|
||||
}
|
||||
|
||||
class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
||||
final _svc = PriceListService(apiClient: ApiClient());
|
||||
bool _loading = true;
|
||||
List<Map<String, dynamic>> _items = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _openEditor({Map<String, dynamic>? item}) async {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
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<bool>(
|
||||
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<int>(
|
||||
value: unitId,
|
||||
items: _fallbackUnits
|
||||
.map((u) => DropdownMenuItem<int>(
|
||||
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<Map<String, dynamic>> get _fallbackUnits => [
|
||||
{'id': 1, 'title': 'عدد'},
|
||||
{'id': 2, 'title': 'کیلوگرم'},
|
||||
{'id': 3, 'title': 'لیتر'},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<Map<String, dynamic>>(
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<String, dynamic> 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<String, dynamic>) {
|
||||
// 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<String, dynamic>) {
|
||||
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<ProductAttributesPage> createState() => _ProductAttributesPageState();
|
||||
}
|
||||
|
||||
class _ProductAttributesPageState extends State<ProductAttributesPage> {
|
||||
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<ProductAttributeItem>(
|
||||
key: _tableKey,
|
||||
config: _buildConfig(t),
|
||||
fromJson: ProductAttributeItem.fromJson,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DataTableConfig<ProductAttributeItem> _buildConfig(AppLocalizations t) {
|
||||
return DataTableConfig<ProductAttributeItem>(
|
||||
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<bool>(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
113
hesabixUI/hesabix_ui/lib/pages/business/products_page.dart
Normal file
113
hesabixUI/hesabix_ui/lib/pages/business/products_page.dart
Normal file
|
|
@ -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<ProductsPage> createState() => _ProductsPageState();
|
||||
}
|
||||
|
||||
class _ProductsPageState extends State<ProductsPage> {
|
||||
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<Map<String, dynamic>>(
|
||||
key: _tableKey,
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<BusinessesPage> {
|
|||
List<BusinessWithPermission> _businesses = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
final AuthStore _authStore = AuthStore();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBusinesses();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
// اطمینان از bind بودن AuthStore برای ApiClient
|
||||
ApiClient.bindAuthStore(_authStore);
|
||||
await _authStore.load();
|
||||
await _loadBusinesses();
|
||||
}
|
||||
|
||||
Future<void> _loadBusinesses() async {
|
||||
|
|
@ -141,6 +150,7 @@ class _BusinessesPageState extends State<BusinessesPage> {
|
|||
return _BusinessCard(
|
||||
business: business,
|
||||
onTap: () => _navigateToBusiness(business.id),
|
||||
authStore: _authStore,
|
||||
isCompact: crossAxisCount > 1,
|
||||
);
|
||||
},
|
||||
|
|
@ -154,20 +164,42 @@ class _BusinessesPageState extends State<BusinessesPage> {
|
|||
}
|
||||
}
|
||||
|
||||
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<String>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
hint: const Text('انتخاب ارز'),
|
||||
items: items
|
||||
.map((c) => DropdownMenuItem<String>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
118
hesabixUI/hesabix_ui/lib/services/category_service.dart
Normal file
118
hesabixUI/hesabix_ui/lib/services/category_service.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import '../core/api_client.dart';
|
||||
|
||||
class CategoryService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
CategoryService(this._apiClient);
|
||||
|
||||
Future<List<Map<String, dynamic>>> getTree({
|
||||
required int businessId,
|
||||
String? type, // 'product' | 'service'
|
||||
}) async {
|
||||
try {
|
||||
final res = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/categories/business/$businessId/tree',
|
||||
data: type != null ? {'type': type} : null,
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
||||
if (items is List) {
|
||||
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> create({
|
||||
required int businessId,
|
||||
int? parentId,
|
||||
required String type, // 'product' | 'service'
|
||||
required String label,
|
||||
}) async {
|
||||
try {
|
||||
final res = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/categories/business/$businessId',
|
||||
data: {
|
||||
'parent_id': parentId,
|
||||
'type': type,
|
||||
'label': label,
|
||||
},
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final item = (data is Map<String, dynamic>) ? data['item'] : null;
|
||||
return Map<String, dynamic>.from(item ?? const {});
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> update({
|
||||
required int businessId,
|
||||
required int categoryId,
|
||||
String? type, // optional
|
||||
String? label,
|
||||
}) async {
|
||||
try {
|
||||
final body = <String, dynamic>{};
|
||||
body['category_id'] = categoryId;
|
||||
if (type != null) body['type'] = type;
|
||||
if (label != null) body['label'] = label;
|
||||
final res = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/categories/business/$businessId/update',
|
||||
data: body,
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final item = (data is Map<String, dynamic>) ? data['item'] : null;
|
||||
return Map<String, dynamic>.from(item ?? const {});
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> move({
|
||||
required int businessId,
|
||||
required int categoryId,
|
||||
int? newParentId,
|
||||
}) async {
|
||||
try {
|
||||
final res = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/categories/business/$businessId/move',
|
||||
data: {
|
||||
'category_id': categoryId,
|
||||
'new_parent_id': newParentId,
|
||||
},
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final item = (data is Map<String, dynamic>) ? data['item'] : null;
|
||||
return Map<String, dynamic>.from(item ?? const {});
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> delete({
|
||||
required int businessId,
|
||||
required int categoryId,
|
||||
}) async {
|
||||
try {
|
||||
final res = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/categories/business/$businessId/delete',
|
||||
data: {
|
||||
'category_id': categoryId,
|
||||
},
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data['deleted'] == true;
|
||||
}
|
||||
return false;
|
||||
} on DioException catch (e) {
|
||||
throw Exception(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
hesabixUI/hesabix_ui/lib/services/price_list_service.dart
Normal file
64
hesabixUI/hesabix_ui/lib/services/price_list_service.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import '../core/api_client.dart';
|
||||
|
||||
class PriceListService {
|
||||
final ApiClient _api;
|
||||
|
||||
PriceListService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
|
||||
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>>(
|
||||
'/api/v1/price-lists/business/$businessId/search',
|
||||
data: body,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listItems({
|
||||
required int businessId,
|
||||
required int priceListId,
|
||||
}) async {
|
||||
final res = await _api.get<Map<String, dynamic>>(
|
||||
'/api/v1/price-lists/business/$businessId/$priceListId/items',
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
||||
if (items is List) {
|
||||
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> upsertItem({
|
||||
required int businessId,
|
||||
required int priceListId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/price-lists/business/$businessId/$priceListId/items',
|
||||
data: payload,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<Map<String, dynamic>> search({
|
||||
required int businessId,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? search,
|
||||
String? sortBy,
|
||||
bool sortDesc = true,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'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<Map<String, dynamic>>(
|
||||
'/api/v1/product-attributes/business/$businessId/search',
|
||||
data: body,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> create({
|
||||
required int businessId,
|
||||
required String title,
|
||||
String? description,
|
||||
}) async {
|
||||
final res = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/product-attributes/business/$businessId',
|
||||
data: {
|
||||
'title': title,
|
||||
if (description != null) 'description': description,
|
||||
},
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getOne({
|
||||
required int businessId,
|
||||
required int id,
|
||||
}) async {
|
||||
final res = await _apiClient.get<Map<String, dynamic>>(
|
||||
'/api/v1/product-attributes/business/$businessId/$id',
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data']?['item'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> update({
|
||||
required int businessId,
|
||||
required int id,
|
||||
String? title,
|
||||
String? description,
|
||||
}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (title != null) body['title'] = title;
|
||||
if (description != null) body['description'] = description;
|
||||
final res = await _apiClient.put<Map<String, dynamic>>(
|
||||
'/api/v1/product-attributes/business/$businessId/$id',
|
||||
data: body,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<bool> delete({
|
||||
required int businessId,
|
||||
required int id,
|
||||
}) async {
|
||||
final res = await _apiClient.delete<Map<String, dynamic>>(
|
||||
'/api/v1/product-attributes/business/$businessId/$id',
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
if (data is Map<String, dynamic>) return data['deleted'] == true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
80
hesabixUI/hesabix_ui/lib/services/product_service.dart
Normal file
80
hesabixUI/hesabix_ui/lib/services/product_service.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import '../core/api_client.dart';
|
||||
|
||||
class ProductService {
|
||||
final ApiClient _api;
|
||||
|
||||
ProductService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
|
||||
|
||||
Future<Map<String, dynamic>> createProduct({
|
||||
required int businessId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/products/business/$businessId',
|
||||
data: payload,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getProduct({
|
||||
required int businessId,
|
||||
required int productId,
|
||||
}) async {
|
||||
final res = await _api.get<Map<String, dynamic>>(
|
||||
'/api/v1/products/business/$businessId/$productId',
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data']?['item'] ?? const {});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> updateProduct({
|
||||
required int businessId,
|
||||
required int productId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final res = await _api.put<Map<String, dynamic>>(
|
||||
'/api/v1/products/business/$businessId/$productId',
|
||||
data: payload,
|
||||
);
|
||||
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
|
||||
}
|
||||
|
||||
Future<bool> 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<bool> 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<Map<String, dynamic>>(
|
||||
'/api/v1/products/business/$businessId/search',
|
||||
data: body,
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
||||
if (items is List && items.isNotEmpty) {
|
||||
final first = Map<String, dynamic>.from(items.first);
|
||||
final foundId = first['id'] as int?;
|
||||
if (excludeProductId != null && foundId == excludeProductId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
hesabixUI/hesabix_ui/lib/services/tax_service.dart
Normal file
55
hesabixUI/hesabix_ui/lib/services/tax_service.dart
Normal file
|
|
@ -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<List<Map<String, dynamic>>> getTaxTypes({required int businessId}) async {
|
||||
try {
|
||||
final res = await _apiClient.get<Map<String, dynamic>>(
|
||||
'/api/v1/tax-types/business/$businessId',
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
if (data is List) {
|
||||
return data
|
||||
.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
}
|
||||
if (data is Map<String, dynamic> && data['items'] is List) {
|
||||
final items = data['items'] as List;
|
||||
return items
|
||||
.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
} on DioException {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getTaxUnits({required int businessId}) async {
|
||||
try {
|
||||
final res = await _apiClient.get<Map<String, dynamic>>(
|
||||
'/api/v1/tax-units/business/$businessId',
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
if (data is List) {
|
||||
return data
|
||||
.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
}
|
||||
if (data is Map<String, dynamic> && data['items'] is List) {
|
||||
final items = data['items'] as List;
|
||||
return items
|
||||
.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
} on DioException {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
hesabixUI/hesabix_ui/lib/services/unit_service.dart
Normal file
26
hesabixUI/hesabix_ui/lib/services/unit_service.dart
Normal file
|
|
@ -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<List<Map<String, dynamic>>> getUnits({required int businessId}) async {
|
||||
try {
|
||||
final res = await _apiClient.get<Map<String, dynamic>>(
|
||||
'/api/v1/units/business/$businessId',
|
||||
);
|
||||
final data = res.data?['data'];
|
||||
final items = (data is Map<String, dynamic>) ? data['items'] : null;
|
||||
if (items is List) {
|
||||
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
} on DioException {
|
||||
// Endpoint may not exist yet; return empty to allow UI fallback
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
154
hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart
Normal file
154
hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart
Normal file
|
|
@ -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<String, String> validateFormData(ProductFormData formData) {
|
||||
final errors = <String, String>{};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CategoryTreeDialog> createState() => _CategoryTreeDialogState();
|
||||
}
|
||||
|
||||
class _CategoryTreeDialogState extends State<CategoryTreeDialog> {
|
||||
late final CategoryService _service;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<Map<String, dynamic>> _tree = const <Map<String, dynamic>>[];
|
||||
// TreeController دیگر استفاده نمیشود
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_service = CategoryService(ApiClient());
|
||||
_fetch();
|
||||
}
|
||||
|
||||
Future<void> _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<TreeNode> _buildTreeNodes(List<Map<String, dynamic>> 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<dynamic>()
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList() ?? const <Map<String, dynamic>>[];
|
||||
|
||||
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<void> _confirmDelete(int? id) async {
|
||||
if (id == null) return;
|
||||
final t = AppLocalizations.of(context);
|
||||
final ok = await showDialog<bool>(
|
||||
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<void> _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<bool>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
hesabixUI/hesabix_ui/lib/widgets/product/README_REDESIGN.md
Normal file
163
hesabixUI/hesabix_ui/lib/widgets/product/README_REDESIGN.md
Normal file
|
|
@ -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. از کنترلر جدید استفاده میشود
|
||||
|
||||
## ویژگیهای آینده
|
||||
|
||||
- [ ] ذخیره خودکار فرم
|
||||
- [ ] اعتبارسنجی سرور
|
||||
- [ ] پشتیبانی از تصاویر کالا
|
||||
- [ ] تاریخچه تغییرات
|
||||
- [ ] قالبهای پیشفرض
|
||||
|
|
@ -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<String, dynamic>? product;
|
||||
final VoidCallback? onSuccess;
|
||||
final AuthStore authStore;
|
||||
|
||||
const ProductFormDialog({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.authStore,
|
||||
this.product,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProductFormDialog> createState() => _ProductFormDialogState();
|
||||
}
|
||||
|
||||
class _ProductFormDialogState extends State<ProductFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final ProductFormController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = ProductFormController(businessId: widget.businessId);
|
||||
_initializeForm();
|
||||
}
|
||||
|
||||
Future<void> _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<Widget> _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<void> _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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProductFormData> onChanged;
|
||||
final List<Map<String, dynamic>> categories;
|
||||
final List<Map<String, dynamic>> attributes;
|
||||
final List<Map<String, dynamic>> 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<int>(
|
||||
value: formData.categoryId,
|
||||
items: categories
|
||||
.map((category) => DropdownMenuItem<int>(
|
||||
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<int>(
|
||||
value: formData.categoryId,
|
||||
items: categories
|
||||
.map((category) => DropdownMenuItem<int>(
|
||||
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<int>.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<String> 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<String>(
|
||||
value: value,
|
||||
groupValue: formData.itemType,
|
||||
onChanged: (val) => _updateFormData(formData.copyWith(itemType: val ?? 'کالا')),
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProductFormData> onChanged;
|
||||
final List<Map<String, dynamic>> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProductFormData> onChanged;
|
||||
final List<Map<String, dynamic>> taxTypes;
|
||||
final List<Map<String, dynamic>> 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<int>(
|
||||
value: formData.taxTypeId,
|
||||
items: taxTypes
|
||||
.map((taxType) => DropdownMenuItem<int>(
|
||||
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<Map<String, dynamic>> effectiveTaxUnits = taxUnits.isNotEmpty ? taxUnits : _fallbackTaxUnits();
|
||||
if (effectiveTaxUnits.isNotEmpty) {
|
||||
return DropdownButtonFormField<int>(
|
||||
value: formData.taxUnitId,
|
||||
items: effectiveTaxUnits
|
||||
.map((taxUnit) => DropdownMenuItem<int>(
|
||||
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<Map<String, dynamic>> _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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
142
hesabixUI/hesabix_ui/test/product_form_test.dart
Normal file
142
hesabixUI/hesabix_ui/test/product_form_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue