progress in commodity

This commit is contained in:
Hesabix 2025-09-30 17:12:53 +03:30
parent a371499e31
commit 6b908eea5d
74 changed files with 7337 additions and 228 deletions

View file

@ -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

View 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)

View file

@ -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)

View 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)

View 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)

View 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)),
},
)

View 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

View 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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View 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)

View 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)

View file

@ -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

View 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")

View 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)

View 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)

View 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)

View 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)

View 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)

View 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

View 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,
}

View file

@ -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

View 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

View file

@ -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

View file

@ -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")

View 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,
}

View 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,
}

View 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,
}

View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -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 ###

View file

@ -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();
}
}

View file

@ -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
}

View file

@ -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);
}
}
}

View file

@ -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('حذف'),
),
],
),
);
}
}

View file

@ -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"
}

View file

@ -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": "عملیات ناموفق بود"
}

View file

@ -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

View file

@ -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';
}

View file

@ -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 => 'عملیات ناموفق بود';
}

View file

@ -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',

View file

@ -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(),
);
}
}

View 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>{};
}
}

View file

@ -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';

View file

@ -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,

View file

@ -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': 'لیتر'},
];
}

View file

@ -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,
),
);
}
}

View file

@ -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)),
],
),
);
}
}

View 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,
),
);
}
}

View file

@ -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;
}
}

View 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);
}
}
}

View 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);
}
}

View file

@ -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;
}
}

View 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;
}
}

View 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>>[];
}
}
}

View 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>>[];
}
}
}

View 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;
}
}

View file

@ -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();
}
}

View 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. از کنترلر جدید استفاده می‌شود
## ویژگی‌های آینده
- [ ] ذخیره خودکار فرم
- [ ] اعتبارسنجی سرور
- [ ] پشتیبانی از تصاویر کالا
- [ ] تاریخچه تغییرات
- [ ] قالب‌های پیش‌فرض

View file

@ -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,
),
),
);
}
}
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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:

View 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);
});
});
}