diff --git a/hesabixAPI/adapters/api/v1/products.py b/hesabixAPI/adapters/api/v1/products.py index d8b734c..e60c86b 100644 --- a/hesabixAPI/adapters/api/v1/products.py +++ b/hesabixAPI/adapters/api/v1/products.py @@ -242,8 +242,8 @@ async def export_products_excel( ("category_id", "دسته"), ("base_sales_price", "قیمت فروش"), ("base_purchase_price", "قیمت خرید"), - ("main_unit_id", "واحد اصلی"), - ("secondary_unit_id", "واحد فرعی"), + ("main_unit", "واحد اصلی"), + ("secondary_unit", "واحد فرعی"), ("track_inventory", "کنترل موجودی"), ("created_at_formatted", "ایجاد"), ] @@ -358,7 +358,7 @@ async def download_products_import_template( headers = [ "code","name","item_type","description","category_id", - "main_unit_id","secondary_unit_id","unit_conversion_factor", + "main_unit","secondary_unit","unit_conversion_factor", "base_sales_price","base_purchase_price","track_inventory", "reorder_point","min_order_qty","lead_time_days", "is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate", @@ -523,7 +523,7 @@ async def import_products_excel( for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']: if k in item: item[k] = _parse_decimal(item.get(k)) - for k in ['reorder_point','min_order_qty','lead_time_days','category_id','main_unit_id','secondary_unit_id','tax_type_id','tax_unit_id']: + for k in ['reorder_point','min_order_qty','lead_time_days','category_id','tax_type_id','tax_unit_id']: if k in item: item[k] = _parse_int(item.get(k)) for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']: @@ -673,8 +673,8 @@ async def export_products_pdf( ("category_id", "دسته"), ("base_sales_price", "قیمت فروش"), ("base_purchase_price", "قیمت خرید"), - ("main_unit_id", "واحد اصلی"), - ("secondary_unit_id", "واحد فرعی"), + ("main_unit", "واحد اصلی"), + ("secondary_unit", "واحد فرعی"), ("track_inventory", "کنترل موجودی"), ("created_at_formatted", "ایجاد"), ] diff --git a/hesabixAPI/adapters/api/v1/schema_models/product.py b/hesabixAPI/adapters/api/v1/schema_models/product.py index 80535a7..0c4e561 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/product.py +++ b/hesabixAPI/adapters/api/v1/schema_models/product.py @@ -18,8 +18,8 @@ class ProductCreateRequest(BaseModel): 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 + main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش") + secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش") unit_conversion_factor: Optional[Decimal] = None base_sales_price: Optional[Decimal] = None @@ -50,8 +50,8 @@ class ProductUpdateRequest(BaseModel): 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 + main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش") + secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش") unit_conversion_factor: Optional[Decimal] = None base_sales_price: Optional[Decimal] = None @@ -83,8 +83,8 @@ class ProductResponse(BaseModel): name: str description: Optional[str] = None category_id: Optional[int] = None - main_unit_id: Optional[int] = None - secondary_unit_id: Optional[int] = None + main_unit: Optional[str] = None + secondary_unit: Optional[str] = None unit_conversion_factor: Optional[Decimal] = None base_sales_price: Optional[Decimal] = None base_sales_note: Optional[str] = None diff --git a/hesabixAPI/adapters/api/v1/tax_types.py b/hesabixAPI/adapters/api/v1/tax_types.py index 5360905..7a03069 100644 --- a/hesabixAPI/adapters/api/v1/tax_types.py +++ b/hesabixAPI/adapters/api/v1/tax_types.py @@ -1,49 +1,59 @@ -from typing import Dict, Any, List +from typing import Dict, Any 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 adapters.db.session import get_db 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 +from sqlalchemy.orm import Session +from adapters.db.models.tax_type import TaxType 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="دریافت لیست نوع‌های مالیات (ثابت)", +@router.get("/", + summary="لیست نوع‌های مالیات", + description="دریافت لیست تمام نوع‌های مالیات استاندارد", response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست نوع‌های مالیات با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست نوع‌های مالیات دریافت شد", + "data": [ + { + "id": 1, + "title": "ارزش افزوده گروه دارو", + "code": "VAT_DRUG", + "description": "مالیات ارزش افزوده برای گروه دارو و تجهیزات پزشکی", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ] + } + } + } + } + } ) -@require_business_access() def list_tax_types( request: Request, - business_id: int, - ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), ) -> 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) - - + """دریافت لیست تمام نوع‌های مالیات استاندارد""" + + items = [ + { + "id": it.id, + "title": it.title, + "code": it.code, + "description": it.description, + "created_at": it.created_at.isoformat(), + "updated_at": it.updated_at.isoformat(), + } + for it in db.query(TaxType).order_by(TaxType.title).all() + ] + return success_response(items, request) \ No newline at end of file diff --git a/hesabixAPI/adapters/api/v1/tax_units.py b/hesabixAPI/adapters/api/v1/tax_units.py index 77392cc..98c29a1 100644 --- a/hesabixAPI/adapters/api/v1/tax_units.py +++ b/hesabixAPI/adapters/api/v1/tax_units.py @@ -1,55 +1,19 @@ -from fastapi import APIRouter, Depends, Request, HTTPException +from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session -from typing import List, Optional -from decimal import Decimal +from typing import Dict, Any 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 +from app.core.responses import success_response router = APIRouter(prefix="/tax-units", tags=["tax-units"]) -alias_router = APIRouter(prefix="/units", tags=["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="دریافت لیست واحدهای مالیاتی یک کسب‌وکار", +@router.get("/", + summary="لیست واحدهای مالیاتی", + description="دریافت لیست تمام واحدهای مالیاتی استاندارد", response_model=SuccessResponse, responses={ 200: { @@ -62,12 +26,9 @@ class TaxUnitResponse(BaseModel): "data": [ { "id": 1, - "business_id": 1, - "name": "مالیات بر ارزش افزوده", - "code": "VAT", - "description": "مالیات بر ارزش افزوده 9 درصد", - "tax_rate": 9.0, - "is_active": True, + "name": "کیلوگرم", + "code": "کیلوگرم", + "description": None, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z" } @@ -75,313 +36,29 @@ class TaxUnitResponse(BaseModel): } } } - }, - 401: { - "description": "کاربر احراز هویت نشده است" - }, - 403: { - "description": "دسترسی غیرمجاز به کسب‌وکار" - }, - 404: { - "description": "کسب‌وکار یافت نشد" } } ) -@alias_router.get("/business/{business_id}") -@require_business_access() -def get_tax_units( +def list_tax_units( request: Request, - business_id: int, - ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db) -) -> dict: - """دریافت لیست واحدهای مالیاتی یک کسب‌وکار""" +) -> Dict[str, Any]: + """دریافت لیست تمام واحدهای مالیاتی استاندارد""" - # Query tax units for the business - tax_units = db.query(TaxUnit).filter( - TaxUnit.business_id == business_id - ).order_by(TaxUnit.name).all() + # Query all tax units (they are global now) + tax_units = db.query(TaxUnit).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)) + tax_unit_dicts.append(tax_unit_dict) - 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": "کسب‌وکار یافت نشد" - } - } -) -@alias_router.post("/business/{business_id}") -@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": "واحد مالیاتی یافت نشد" - } - } -) -@alias_router.put("/{tax_unit_id}") -@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": "امکان حذف واحد مالیاتی به دلیل استفاده در محصولات وجود ندارد" - } - } -) -@alias_router.delete("/{tax_unit_id}") -@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) + return success_response(tax_unit_dicts, request) \ No newline at end of file diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index e861578..01498e3 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -36,5 +36,6 @@ 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 +from .tax_type import TaxType # noqa: F401 from .bank_account import BankAccount # noqa: F401 from .petty_cash import PettyCash # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/product.py b/hesabixAPI/adapters/db/models/product.py index 6188a74..5cd4b6f 100644 --- a/hesabixAPI/adapters/db/models/product.py +++ b/hesabixAPI/adapters/db/models/product.py @@ -56,8 +56,8 @@ class Product(Base): 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) + main_unit: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="واحد اصلی شمارش") + secondary_unit: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="واحد فرعی شمارش") unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True) # قیمت‌های پایه (نمایشی) diff --git a/hesabixAPI/adapters/db/models/tax_type.py b/hesabixAPI/adapters/db/models/tax_type.py new file mode 100644 index 0000000..85abcb9 --- /dev/null +++ b/hesabixAPI/adapters/db/models/tax_type.py @@ -0,0 +1,24 @@ +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column +from adapters.db.session import Base + + +class TaxType(Base): + """ + موجودیت نوع مالیات + - نگهداری انواع مالیات استاندارد سازمان امور مالیاتی + - عمومی برای همه کسب‌وکارها (بدون وابستگی به کسب‌وکار خاص) + - مثال: ارزش افزوده گروه «دارو»، «دخانیات» و ... + """ + + __tablename__ = "tax_types" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(255), nullable=False, comment="عنوان نوع مالیات") + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True, comment="کد یکتا برای نوع مالیات") + description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/adapters/db/models/tax_unit.py b/hesabixAPI/adapters/db/models/tax_unit.py index 56be926..92a9efb 100644 --- a/hesabixAPI/adapters/db/models/tax_unit.py +++ b/hesabixAPI/adapters/db/models/tax_unit.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import String, Integer, Boolean, DateTime, Text, Numeric +from sqlalchemy import String, Integer, DateTime, Text from sqlalchemy.orm import Mapped, mapped_column from adapters.db.session import Base @@ -14,11 +14,8 @@ class TaxUnit(Base): __tablename__ = "tax_units" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - business_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="شناسه کسب‌وکار") name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی") code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی") description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات") - tax_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="نرخ مالیات (درصد)") - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال/غیرفعال") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/hesabixAPI/adapters/db/repositories/product_repository.py b/hesabixAPI/adapters/db/repositories/product_repository.py index e28deba..db54340 100644 --- a/hesabixAPI/adapters/db/repositories/product_repository.py +++ b/hesabixAPI/adapters/db/repositories/product_repository.py @@ -78,8 +78,8 @@ class ProductRepository(BaseRepository[Product]): "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, + "main_unit": p.main_unit, + "secondary_unit": p.secondary_unit, "unit_conversion_factor": p.unit_conversion_factor, "base_sales_price": p.base_sales_price, "base_sales_note": p.base_sales_note, @@ -125,9 +125,14 @@ class ProductRepository(BaseRepository[Product]): obj = self.db.get(Product, product_id) if not obj: return None + # اجازه بده فیلدهای خاص حتی اگر None باشند هم ست شوند + nullable_overrides = {"main_unit_id", "secondary_unit_id", "unit_conversion_factor"} for k, v in data.items(): - if hasattr(obj, k) and v is not None: - setattr(obj, k, v) + if hasattr(obj, k): + if k in nullable_overrides: + setattr(obj, k, v) + elif v is not None: + setattr(obj, k, v) self.db.commit() self.db.refresh(obj) return obj diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index b54d9e5..3b721e9 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -21,7 +21,6 @@ from adapters.api.v1.bank_accounts import router as bank_accounts_router from adapters.api.v1.cash_registers import router as cash_registers_router from adapters.api.v1.petty_cash import router as petty_cash_router from adapters.api.v1.tax_units import router as tax_units_router -from adapters.api.v1.tax_units import alias_router as units_alias_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 @@ -301,7 +300,6 @@ def create_app() -> FastAPI: application.include_router(cash_registers_router, prefix=settings.api_v1_prefix) application.include_router(petty_cash_router, prefix=settings.api_v1_prefix) application.include_router(tax_units_router, prefix=settings.api_v1_prefix) - application.include_router(units_alias_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix) # Support endpoints diff --git a/hesabixAPI/app/services/price_list_service.py b/hesabixAPI/app/services/price_list_service.py index 0a40588..b05139d 100644 --- a/hesabixAPI/app/services/price_list_service.py +++ b/hesabixAPI/app/services/price_list_service.py @@ -83,7 +83,7 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload 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]: + if payload.unit_id is not None and payload.unit_id not in [pr.main_unit, pr.secondary_unit]: raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400) repo = PriceItemRepository(db) diff --git a/hesabixAPI/app/services/product_service.py b/hesabixAPI/app/services/product_service.py index 6d09190..39d82ff 100644 --- a/hesabixAPI/app/services/product_service.py +++ b/hesabixAPI/app/services/product_service.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, Tuple from sqlalchemy.orm import Session from sqlalchemy import select, and_, func from decimal import Decimal @@ -39,9 +39,20 @@ def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> 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: +def _validate_units(main_unit: Optional[str], secondary_unit: Optional[str], factor: Optional[Decimal]) -> None: + if secondary_unit and not factor: raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400) +def _validate_unit_string(unit: Optional[str]) -> Optional[str]: + """Validate and clean unit string""" + if unit is None: + return None + cleaned = str(unit).strip() + if not cleaned: + return None + if len(cleaned) > 32: + raise ApiError("INVALID_UNIT_LENGTH", "واحد شمارش نمی‌تواند بیش از 32 کاراکتر باشد", http_status=400) + return cleaned + def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute_ids: Optional[List[int]]) -> None: @@ -64,7 +75,10 @@ def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute 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) + # Validate and clean unit strings + main_unit = _validate_unit_string(payload.main_unit) + secondary_unit = _validate_unit_string(payload.secondary_unit) + _validate_units(main_unit, secondary_unit, payload.unit_conversion_factor) code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None if code: @@ -81,8 +95,8 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest) 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, + main_unit=main_unit, + secondary_unit=secondary_unit, unit_conversion_factor=payload.unit_conversion_factor, base_sales_price=payload.base_sales_price, base_sales_note=payload.base_sales_note, @@ -103,7 +117,14 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest) _upsert_attributes(db, obj.id, business_id, payload.attribute_ids) - return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)} + data = _to_dict(obj) + # enrich titles from payload if provided + if getattr(payload, 'main_unit_title', None): + data["main_unit_title"] = str(getattr(payload, 'main_unit_title')) + if getattr(payload, 'secondary_unit_title', None): + data["secondary_unit_title"] = str(getattr(payload, 'secondary_unit_title')) + + return {"message": "PRODUCT_CREATED", "data": data} def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: @@ -144,9 +165,13 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod 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) + # از فیلدهای explicitly-set برای تشخیص پاک‌سازی (None) استفاده کن + fields_set = getattr(payload, 'model_fields_set', getattr(payload, '__fields_set__', set())) + # Validate and clean unit strings + main_unit_val = (_validate_unit_string(payload.main_unit) if 'main_unit' in fields_set else obj.main_unit) + secondary_unit_val = (_validate_unit_string(payload.secondary_unit) if 'secondary_unit' in fields_set else obj.secondary_unit) + factor_val = payload.unit_conversion_factor if 'unit_conversion_factor' in fields_set else obj.unit_conversion_factor + _validate_units(main_unit_val, secondary_unit_val, factor_val) updated = repo.update( product_id, @@ -155,8 +180,8 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod 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, + main_unit=main_unit_val if 'main_unit' in fields_set else None, + secondary_unit=secondary_unit_val if 'secondary_unit' in fields_set else None, unit_conversion_factor=payload.unit_conversion_factor, base_sales_price=payload.base_sales_price, base_sales_note=payload.base_sales_note, @@ -178,7 +203,8 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod return None _upsert_attributes(db, product_id, business_id, payload.attribute_ids) - return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)} + data = _to_dict(updated) + return {"message": "PRODUCT_UPDATED", "data": data} def delete_product(db: Session, product_id: int, business_id: int) -> bool: @@ -198,8 +224,8 @@ def _to_dict(obj: Product) -> Dict[str, Any]: "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, + "main_unit": obj.main_unit, + "secondary_unit": obj.secondary_unit, "unit_conversion_factor": obj.unit_conversion_factor, "base_sales_price": obj.base_sales_price, "base_sales_note": obj.base_sales_note, diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 2d49baa..84a012c 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -65,6 +65,7 @@ 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_type.py adapters/db/models/tax_unit.py adapters/db/models/user.py adapters/db/models/support/__init__.py @@ -138,6 +139,10 @@ hesabix_api.egg-info/top_level.txt migrations/env.py migrations/versions/1f0abcdd7300_add_petty_cash_table.py migrations/versions/20250102_000001_seed_support_data.py +migrations/versions/20250106_000001_fix_tax_types_structure.py +migrations/versions/20250106_000002_remove_tax_fields.py +migrations/versions/20250106_000003_cleanup_tax_units_table.py +migrations/versions/20250106_000004_seed_tax_units_list.py migrations/versions/20250117_000003_add_business_table.py migrations/versions/20250117_000004_add_business_contact_fields.py migrations/versions/20250117_000005_add_business_geographic_fields.py @@ -174,10 +179,13 @@ migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py migrations/versions/20251002_000101_add_bank_accounts_table.py migrations/versions/20251003_000201_add_cash_registers_table.py migrations/versions/20251003_010501_add_name_to_cash_registers.py +migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/5553f8745c6e_add_support_tables.py +migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py migrations/versions/9f9786ae7191_create_tax_units_table.py migrations/versions/a1443c153b47_merge_heads.py +migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py migrations/versions/c302bc2f2cb8_remove_person_type_column.py migrations/versions/caf3f4ef4b76_add_tax_units_table.py migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py diff --git a/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py b/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py index ba1c5d7..61a57c4 100644 --- a/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py +++ b/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py @@ -17,8 +17,17 @@ depends_on = None def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('petty_cash', + # Check if table already exists + connection = op.get_bind() + result = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'petty_cash' + """)).fetchone() + + if result[0] == 0: + op.create_table('petty_cash', 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, comment='نام تنخواه گردان'), @@ -33,11 +42,11 @@ def upgrade() -> None: sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code') - ) - op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False) - op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False) - op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False) - op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False) + ) + op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False) + op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False) + op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False) + op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False) # ### end Alembic commands ### diff --git a/hesabixAPI/migrations/versions/20250106_000001_fix_tax_types_structure.py b/hesabixAPI/migrations/versions/20250106_000001_fix_tax_types_structure.py new file mode 100644 index 0000000..081b1be --- /dev/null +++ b/hesabixAPI/migrations/versions/20250106_000001_fix_tax_types_structure.py @@ -0,0 +1,68 @@ +"""fix tax_types structure - remove business_id and make it global + +Revision ID: 20250106_000001 +Revises: 20251006_000001 +Create Date: 2025-01-06 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250106_000001' +down_revision = '20251006_000001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # First, clear existing data to avoid conflicts + op.execute("DELETE FROM tax_types") + + # Drop the business_id column (if it exists) + try: + op.drop_column('tax_types', 'business_id') + except Exception: + pass # Column doesn't exist + + # Make code column NOT NULL and UNIQUE + try: + op.alter_column('tax_types', 'code', + existing_type=sa.String(length=64), + nullable=False) + except Exception: + pass # Already NOT NULL + + try: + op.create_unique_constraint('uq_tax_types_code', 'tax_types', ['code']) + except Exception: + pass # Constraint already exists + + # Add tax_rate column (if it doesn't exist) + try: + op.add_column('tax_types', sa.Column('tax_rate', sa.Numeric(5, 2), nullable=True, comment='نرخ مالیات (درصد)')) + except Exception: + pass # Column already exists + + # Drop the old business_id index (if it exists) + try: + op.drop_index('ix_tax_types_business_id', table_name='tax_types') + except Exception: + pass + + +def downgrade() -> None: + # Add business_id column back + op.add_column('tax_types', sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب‌وکار')) + + # Remove unique constraint on code + op.drop_constraint('uq_tax_types_code', 'tax_types', type_='unique') + + # Make code nullable again + op.alter_column('tax_types', 'code', nullable=True) + + # Remove tax_rate column + op.drop_column('tax_types', 'tax_rate') + + # Recreate business_id index + op.create_index('ix_tax_types_business_id', 'tax_types', ['business_id'], unique=False) diff --git a/hesabixAPI/migrations/versions/20250106_000002_remove_tax_fields.py b/hesabixAPI/migrations/versions/20250106_000002_remove_tax_fields.py new file mode 100644 index 0000000..056fd0c --- /dev/null +++ b/hesabixAPI/migrations/versions/20250106_000002_remove_tax_fields.py @@ -0,0 +1,37 @@ +"""remove is_active and tax_rate fields from tax_types + +Revision ID: 20250106_000002 +Revises: 20250106_000001 +Create Date: 2025-01-06 12:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250106_000002' +down_revision = '20250106_000001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Remove is_active column (if it exists) + try: + op.drop_column('tax_types', 'is_active') + except Exception: + pass # Column doesn't exist + + # Remove tax_rate column (if it exists) + try: + op.drop_column('tax_types', 'tax_rate') + except Exception: + pass # Column doesn't exist + + +def downgrade() -> None: + # Add tax_rate column back + op.add_column('tax_types', sa.Column('tax_rate', sa.Numeric(5, 2), nullable=True, comment='نرخ مالیات (درصد)')) + + # Add is_active column back + op.add_column('tax_types', sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال')) diff --git a/hesabixAPI/migrations/versions/20250106_000003_cleanup_tax_units_table.py b/hesabixAPI/migrations/versions/20250106_000003_cleanup_tax_units_table.py new file mode 100644 index 0000000..4f8a2e8 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250106_000003_cleanup_tax_units_table.py @@ -0,0 +1,50 @@ +"""cleanup tax_units table: drop business_id, tax_rate, is_active + +Revision ID: 20250106_000003 +Revises: 7891282548e9 +Create Date: 2025-10-06 12:55:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250106_000003' +down_revision = '7891282548e9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop columns if exist (idempotent behavior) + try: + op.drop_index('ix_tax_units_business_id', table_name='tax_units') + except Exception: + pass + + for col in ('business_id', 'tax_rate', 'is_active'): + try: + op.drop_column('tax_units', col) + except Exception: + pass + + +def downgrade() -> None: + # Recreate columns (best-effort) + try: + op.add_column('tax_units', sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب‌وکار')) + op.create_index('ix_tax_units_business_id', 'tax_units', ['business_id']) + except Exception: + pass + + try: + op.add_column('tax_units', sa.Column('tax_rate', sa.Numeric(5, 2), nullable=True, comment='نرخ مالیات (درصد)')) + except Exception: + pass + + try: + op.add_column('tax_units', sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال')) + except Exception: + pass + + diff --git a/hesabixAPI/migrations/versions/20250106_000004_seed_tax_units_list.py b/hesabixAPI/migrations/versions/20250106_000004_seed_tax_units_list.py new file mode 100644 index 0000000..acbad5f --- /dev/null +++ b/hesabixAPI/migrations/versions/20250106_000004_seed_tax_units_list.py @@ -0,0 +1,74 @@ +"""seed standard measurement units into tax_units + +Revision ID: 20250106_000004 +Revises: 20250106_000003 +Create Date: 2025-10-06 13:10:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250106_000004' +down_revision = '20250106_000003' +branch_labels = None +depends_on = None + + +UNIT_NAMES = [ + "بانكه", "برگ", "بسته", "بشكه", "بطری", "بندیل", "پاکت", "پالت", "تانكر", "تخته", + "تن", "تن کیلومتر", "توپ", "تیوب", "ثانیه", "ثوب", "جام", "جعبه", "جفت", "جلد", + "چلیك", "حلب", "حلقه (رول)", "حلقه (دیسک)", "حلقه (رینگ)", "دبه", "دست", "دستگاه", + "دقیقه", "دوجین", "روز", "رول", "ساشه", "ساعت", "سال", "سانتی متر", + "سانتی متر مربع", "سبد", "ست", "سطل", "سیلندر", "شاخه", "شانه", "شعله", "شیت", + "صفحه", "طاقه", "طغرا", "عدد", "عدل", "فاقد بسته بندی", "فروند", "فوت مربع", "قالب", + "قراص", "قراصه (bundle)", "قرقره", "قطعه", "قوطي", "قیراط", "کارتن", + "کارتن (master case)", "کلاف", "کپسول", "کیسه", "کیلوگرم", "کیلومتر", "کیلووات ساعت", + "گالن", "گرم", "گیگابایت بر ثانیه", "لنگه", "لیتر", "لیوان", "ماه", "متر", + "متر مربع", "متر مكعب", "مخزن", "مگاوات ساعت", "میلي گرم", "میلي لیتر", "میلي متر", + "نخ", "نسخه (جلد)", "نفر", "نفر- ساعت", "نوبت", "نیم دوجین", "واحد", "ورق", "ویال", +] + + +def _slugify(name: str) -> str: + # Create a simple ASCII-ish code: replace spaces and special chars with underscore, keep letters/numbers + code = name + for ch in [' ', '-', '(', ')', '–', 'ـ', '،', '/', '\\']: + code = code.replace(ch, '_') + code = code.replace('‌', '_') # zero-width non-joiner + # collapse underscores + while '__' in code: + code = code.replace('__', '_') + return code.strip('_').upper() + + +def upgrade() -> None: + conn = op.get_bind() + + # Insert units if not already present (by code) + for name in UNIT_NAMES: + code = _slugify(name) + exists = conn.execute(sa.text("SELECT id FROM tax_units WHERE code = :code LIMIT 1"), {"code": code}).fetchone() + if not exists: + conn.execute( + sa.text( + """ + INSERT INTO tax_units (name, code, description, created_at, updated_at) + VALUES (:name, :code, :description, NOW(), NOW()) + """ + ), + {"name": name, "code": code, "description": None}, + ) + conn.commit() + + +def downgrade() -> None: + conn = op.get_bind() + # Remove only units we added (by code set) + codes = [_slugify(n) for n in UNIT_NAMES] + conn.execute( + sa.text("DELETE FROM tax_units WHERE code IN :codes"), + {"codes": tuple(codes)}, + ) + + diff --git a/hesabixAPI/migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py b/hesabixAPI/migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py new file mode 100644 index 0000000..fa60cba --- /dev/null +++ b/hesabixAPI/migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py @@ -0,0 +1,68 @@ +"""add tax_types table and ensure product FKs + +Revision ID: 20251006_000001 +Revises: caf3f4ef4b76 +Create Date: 2025-10-06 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20251006_000001' +down_revision = 'caf3f4ef4b76' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Check if table already exists before creating it + try: + op.create_table( + 'tax_types', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('business_id', sa.Integer(), nullable=False, index=True, comment='شناسه کسب‌وکار'), + sa.Column('title', sa.String(length=255), nullable=False, comment='عنوان نوع مالیات'), + sa.Column('code', sa.String(length=64), nullable=True, comment='کد یکتا برای نوع مالیات'), + sa.Column('description', sa.Text(), 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, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')), + ) + except Exception: + pass # Table already exists + + # Create indexes (if they don't exist) + try: + op.create_index(op.f('ix_tax_types_business_id'), 'tax_types', ['business_id'], unique=False) + except Exception: + pass # Index already exists + + try: + op.create_index(op.f('ix_tax_types_code'), 'tax_types', ['code'], unique=False) + except Exception: + pass # Index already exists + + # Ensure product indices exist (idempotent) + try: + op.create_index(op.f('ix_products_tax_type_id'), 'products', ['tax_type_id'], unique=False) + except Exception: + pass + try: + op.create_index(op.f('ix_products_tax_unit_id'), 'products', ['tax_unit_id'], unique=False) + except Exception: + pass + + +def downgrade() -> None: + try: + op.drop_index(op.f('ix_tax_types_code'), table_name='tax_types') + except Exception: + pass + try: + op.drop_index(op.f('ix_tax_types_business_id'), table_name='tax_types') + except Exception: + pass + op.drop_table('tax_types') + + diff --git a/hesabixAPI/migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py b/hesabixAPI/migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py new file mode 100644 index 0000000..f810c04 --- /dev/null +++ b/hesabixAPI/migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py @@ -0,0 +1,24 @@ +"""merge tax_types and unit fields heads + +Revision ID: 7891282548e9 +Revises: 20250106_000002, b2b68cf299a3 +Create Date: 2025-10-06 20:20:43.839460 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7891282548e9' +down_revision = ('20250106_000002', 'b2b68cf299a3') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/hesabixAPI/migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py b/hesabixAPI/migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py new file mode 100644 index 0000000..b745118 --- /dev/null +++ b/hesabixAPI/migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py @@ -0,0 +1,77 @@ +"""convert_unit_fields_to_string + +Revision ID: b2b68cf299a3 +Revises: c302bc2f2cb8 +Create Date: 2025-10-06 11:17:52.851690 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b2b68cf299a3' +down_revision = 'c302bc2f2cb8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Check if columns already exist before adding them + try: + op.add_column('products', sa.Column('main_unit', sa.String(length=32), nullable=True, comment='واحد اصلی شمارش')) + except Exception: + pass # Column already exists + + try: + op.add_column('products', sa.Column('secondary_unit', sa.String(length=32), nullable=True, comment='واحد فرعی شمارش')) + except Exception: + pass # Column already exists + + # Create indexes for new columns (if they don't exist) + try: + op.create_index('ix_products_main_unit', 'products', ['main_unit']) + except Exception: + pass # Index already exists + + try: + op.create_index('ix_products_secondary_unit', 'products', ['secondary_unit']) + except Exception: + pass # Index already exists + + # Drop old integer columns and their indexes (if they exist) + try: + op.drop_index('ix_products_main_unit_id', table_name='products') + except Exception: + pass # Index doesn't exist + + try: + op.drop_index('ix_products_secondary_unit_id', table_name='products') + except Exception: + pass # Index doesn't exist + + try: + op.drop_column('products', 'main_unit_id') + except Exception: + pass # Column doesn't exist + + try: + op.drop_column('products', 'secondary_unit_id') + except Exception: + pass # Column doesn't exist + + +def downgrade() -> None: + # Add back integer columns + op.add_column('products', sa.Column('main_unit_id', sa.Integer(), nullable=True)) + op.add_column('products', sa.Column('secondary_unit_id', sa.Integer(), nullable=True)) + + # Create indexes for integer columns + op.create_index('ix_products_main_unit_id', 'products', ['main_unit_id']) + op.create_index('ix_products_secondary_unit_id', 'products', ['secondary_unit_id']) + + # Drop string columns and their indexes + op.drop_index('ix_products_main_unit', table_name='products') + op.drop_index('ix_products_secondary_unit', table_name='products') + op.drop_column('products', 'main_unit') + op.drop_column('products', 'secondary_unit') diff --git a/hesabixAPI/migrations/versions/c302bc2f2cb8_remove_person_type_column.py b/hesabixAPI/migrations/versions/c302bc2f2cb8_remove_person_type_column.py index 8ab2629..816d0b5 100644 --- a/hesabixAPI/migrations/versions/c302bc2f2cb8_remove_person_type_column.py +++ b/hesabixAPI/migrations/versions/c302bc2f2cb8_remove_person_type_column.py @@ -17,8 +17,18 @@ depends_on = None def upgrade() -> None: - # حذف ستون person_type از جدول persons - op.drop_column('persons', 'person_type') + # Check if column exists before dropping + connection = op.get_bind() + result = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'persons' + AND column_name = 'person_type' + """)).fetchone() + + if result[0] > 0: + op.drop_column('persons', 'person_type') def downgrade() -> None: diff --git a/hesabixAPI/scripts/seed_tax_types.py b/hesabixAPI/scripts/seed_tax_types.py new file mode 100644 index 0000000..6dfa5c1 --- /dev/null +++ b/hesabixAPI/scripts/seed_tax_types.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Script to seed standard tax types from Iranian Tax Organization +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.orm import Session +from adapters.db.session import get_db +from adapters.db.models.tax_type import TaxType + +def seed_tax_types(): + """Seed standard tax types""" + + # Standard tax types from Iranian Tax Organization + tax_types = [ + { + "title": "ارزش افزوده گروه دارو", + "code": "VAT_DRUG", + "description": "مالیات ارزش افزوده برای گروه دارو و تجهیزات پزشکی" + }, + { + "title": "ارزش افزوده گروه دخانیات", + "code": "VAT_TOBACCO", + "description": "مالیات ارزش افزوده برای گروه دخانیات" + }, + { + "title": "ارزش افزوده گروه خودرو", + "code": "VAT_AUTO", + "description": "مالیات ارزش افزوده برای گروه خودرو و قطعات" + }, + { + "title": "ارزش افزوده گروه مواد غذایی", + "code": "VAT_FOOD", + "description": "مالیات ارزش افزوده برای گروه مواد غذایی" + }, + { + "title": "ارزش افزوده گروه پوشاک", + "code": "VAT_CLOTHING", + "description": "مالیات ارزش افزوده برای گروه پوشاک و منسوجات" + }, + { + "title": "ارزش افزوده گروه ساختمان", + "code": "VAT_CONSTRUCTION", + "description": "مالیات ارزش افزوده برای گروه ساختمان و مصالح" + }, + { + "title": "ارزش افزوده گروه خدمات", + "code": "VAT_SERVICES", + "description": "مالیات ارزش افزوده برای گروه خدمات" + }, + { + "title": "ارزش افزوده گروه کالاهای عمومی", + "code": "VAT_GENERAL", + "description": "مالیات ارزش افزوده برای کالاهای عمومی" + }, + { + "title": "مالیات بر درآمد", + "code": "INCOME_TAX", + "description": "مالیات بر درآمد کسب و کار" + }, + { + "title": "مالیات بر ارزش افزوده صفر", + "code": "VAT_ZERO", + "description": "کالاها و خدمات معاف از مالیات ارزش افزوده" + } + ] + + db = next(get_db()) + + try: + # Clear existing data + db.query(TaxType).delete() + db.commit() + + # Insert new data + for tax_data in tax_types: + tax_type = TaxType(**tax_data) + db.add(tax_type) + + db.commit() + print(f"✅ Successfully seeded {len(tax_types)} tax types") + + # Display seeded data + print("\n📋 Seeded tax types:") + for tax_type in db.query(TaxType).order_by(TaxType.title).all(): + print(f" - {tax_type.title} ({tax_type.code})") + + except Exception as e: + db.rollback() + print(f"❌ Error seeding tax types: {e}") + raise + finally: + db.close() + +if __name__ == "__main__": + seed_tax_types() diff --git a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart index a96dbd1..3365d85 100644 --- a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart +++ b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart @@ -3,7 +3,6 @@ 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 '../services/price_list_service.dart'; import '../services/currency_service.dart'; @@ -16,7 +15,6 @@ class ProductFormController extends ChangeNotifier { late final ProductService _productService; late final CategoryService _categoryService; late final ProductAttributeService _attributeService; - late final UnitService _unitService; late final TaxService _taxService; late final PriceListService _priceListService; late final CurrencyService _currencyService; @@ -29,7 +27,6 @@ class ProductFormController extends ChangeNotifier { // Reference data List> _categories = []; List> _attributes = []; - List> _units = []; List> _taxTypes = []; List> _taxUnits = []; List> _priceLists = []; @@ -49,7 +46,6 @@ class ProductFormController extends ChangeNotifier { _productService = ProductService(apiClient: _apiClient); _categoryService = CategoryService(_apiClient); _attributeService = ProductAttributeService(apiClient: _apiClient); - _unitService = UnitService(apiClient: _apiClient); _taxService = TaxService(apiClient: _apiClient); _priceListService = PriceListService(apiClient: _apiClient); _currencyService = CurrencyService(_apiClient); @@ -61,7 +57,6 @@ class ProductFormController extends ChangeNotifier { String? get errorMessage => _errorMessage; List> get categories => _categories; List> get attributes => _attributes; - List> get units => _units; List> get taxTypes => _taxTypes; List> get taxUnits => _taxUnits; List> get priceLists => _priceLists; @@ -139,20 +134,8 @@ class ProductFormController extends ChangeNotifier { } } } - // 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(); @@ -178,23 +161,17 @@ class ProductFormController extends ChangeNotifier { _attributes = []; } - // Load units - try { - _units = await _unitService.getUnits(businessId: businessId); - } catch (_) { - _units = []; - } // Load tax types try { - _taxTypes = await _taxService.getTaxTypes(businessId: businessId); + _taxTypes = await _taxService.getTaxTypes(); } catch (_) { _taxTypes = []; } // Load tax units try { - _taxUnits = await _taxService.getTaxUnits(businessId: businessId); + _taxUnits = await _taxService.getTaxUnits(); } catch (_) { _taxUnits = []; } diff --git a/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart b/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart new file mode 100644 index 0000000..fd4c49d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart @@ -0,0 +1,120 @@ +class InvoiceLineItem { + final int? productId; + final String? productCode; + final String? productName; + + final String? mainUnit; + final String? secondaryUnit; + final num? unitConversionFactor; // 1 main = factor * secondary + + String? selectedUnit; + + num quantity; + + // unit price handling + String unitPriceSource; // manual | base | priceList + num unitPrice; // price per selected unit + + // base prices on main unit (as provided by product) + final num? baseSalesPriceMainUnit; + final num? basePurchasePriceMainUnit; + + // discount + String discountType; // percent | amount + num discountValue; // either percentage (0-100) or absolute amount + + // tax + num taxRate; // percent, editable by user + + // inventory/constraints + final int? minOrderQty; + final bool trackInventory; + + // presentation + String? description; + + InvoiceLineItem({ + this.productId, + this.productCode, + this.productName, + this.mainUnit, + this.secondaryUnit, + this.unitConversionFactor, + this.selectedUnit, + this.description, + this.unitPriceSource = 'base', + this.unitPrice = 0, + this.quantity = 1, + this.discountType = 'amount', + this.discountValue = 0, + this.taxRate = 0, + this.baseSalesPriceMainUnit, + this.basePurchasePriceMainUnit, + this.minOrderQty, + this.trackInventory = false, + }); + + InvoiceLineItem copyWith({ + int? productId, + String? productCode, + String? productName, + String? mainUnit, + String? secondaryUnit, + num? unitConversionFactor, + String? selectedUnit, + num? quantity, + String? unitPriceSource, + num? unitPrice, + String? discountType, + num? discountValue, + num? taxRate, + String? description, + num? baseSalesPriceMainUnit, + num? basePurchasePriceMainUnit, + int? minOrderQty, + bool? trackInventory, + }) { + return InvoiceLineItem( + productId: productId ?? this.productId, + productCode: productCode ?? this.productCode, + productName: productName ?? this.productName, + mainUnit: mainUnit ?? this.mainUnit, + secondaryUnit: secondaryUnit ?? this.secondaryUnit, + unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor, + selectedUnit: selectedUnit ?? this.selectedUnit, + quantity: quantity ?? this.quantity, + unitPriceSource: unitPriceSource ?? this.unitPriceSource, + unitPrice: unitPrice ?? this.unitPrice, + discountType: discountType ?? this.discountType, + discountValue: discountValue ?? this.discountValue, + taxRate: taxRate ?? this.taxRate, + description: description ?? this.description, + baseSalesPriceMainUnit: baseSalesPriceMainUnit ?? this.baseSalesPriceMainUnit, + basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit, + minOrderQty: minOrderQty ?? this.minOrderQty, + trackInventory: trackInventory ?? this.trackInventory, + ); + } + + num get subtotal => quantity * unitPrice; + + num get discountAmount { + if (discountType == 'percent') { + final p = discountValue; + if (p <= 0) return 0; + return subtotal * (p / 100); + } + return discountValue; + } + + num get taxableAmount { + final base = subtotal - discountAmount; + return base < 0 ? 0 : base; + } + + num get taxAmount => taxableAmount * (taxRate / 100); + + num get total => taxableAmount + taxAmount; +} + + diff --git a/hesabixUI/hesabix_ui/lib/models/product_form_data.dart b/hesabixUI/hesabix_ui/lib/models/product_form_data.dart index b642e89..408b24c 100644 --- a/hesabixUI/hesabix_ui/lib/models/product_form_data.dart +++ b/hesabixUI/hesabix_ui/lib/models/product_form_data.dart @@ -19,8 +19,8 @@ class ProductFormData { String? basePurchaseNote; // Units - int? mainUnitId; - int? secondaryUnitId; + String? mainUnit; + String? secondaryUnit; num unitConversionFactor; // Taxes @@ -49,8 +49,8 @@ class ProductFormData { this.basePurchasePrice, this.baseSalesNote, this.basePurchaseNote, - this.mainUnitId, - this.secondaryUnitId, + this.mainUnit = 'عدد', + this.secondaryUnit, this.unitConversionFactor = 1, this.isSalesTaxable = false, this.isPurchaseTaxable = false, @@ -76,8 +76,8 @@ class ProductFormData { num? basePurchasePrice, String? baseSalesNote, String? basePurchaseNote, - int? mainUnitId, - int? secondaryUnitId, + String? mainUnit, + String? secondaryUnit, num? unitConversionFactor, bool? isSalesTaxable, bool? isPurchaseTaxable, @@ -102,8 +102,8 @@ class ProductFormData { basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice, baseSalesNote: baseSalesNote ?? this.baseSalesNote, basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote, - mainUnitId: mainUnitId ?? this.mainUnitId, - secondaryUnitId: secondaryUnitId ?? this.secondaryUnitId, + mainUnit: mainUnit ?? this.mainUnit, + secondaryUnit: secondaryUnit ?? this.secondaryUnit, unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor, isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable, isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable, @@ -134,9 +134,9 @@ class ProductFormData { 'is_purchase_taxable': isPurchaseTaxable, 'sales_tax_rate': salesTaxRate ?? 0, 'purchase_tax_rate': purchaseTaxRate ?? 0, - // Keep optional IDs and factor as-is (do not force zero) - 'main_unit_id': mainUnitId, - 'secondary_unit_id': secondaryUnitId, + // Units as strings + 'main_unit': mainUnit, + 'secondary_unit': secondaryUnit, 'unit_conversion_factor': unitConversionFactor, 'base_sales_note': baseSalesNote, 'base_purchase_note': basePurchaseNote, @@ -160,8 +160,8 @@ class ProductFormData { 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?, + mainUnit: product['main_unit']?.toString(), + secondaryUnit: product['secondary_unit']?.toString(), unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1, baseSalesNote: product['base_sales_note']?.toString(), basePurchaseNote: product['base_purchase_note']?.toString(), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart index be48229..e7dca42 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart @@ -16,6 +16,8 @@ import '../../core/date_utils.dart'; import '../../models/invoice_type_model.dart'; import '../../models/customer_model.dart'; import '../../models/person_model.dart'; +import '../../widgets/invoice/line_items_table.dart'; +import '../../utils/number_formatters.dart'; class NewInvoicePage extends StatefulWidget { final int businessId; @@ -50,11 +52,37 @@ class _NewInvoicePageState extends State with SingleTickerProvid int? _selectedCurrencyId; String? _invoiceTitle; String? _invoiceReference; + // جمع‌های محاسباتی ردیف‌ها + num _sumSubtotal = 0; + num _sumDiscount = 0; + num _sumTax = 0; + num _sumTotal = 0; @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 4, vsync: this); // شروع با 4 تب + // تنظیم ارز پیش‌فرض از AuthStore + _selectedCurrencyId = widget.authStore.selectedCurrencyId; + } + + + // محاسبه تعداد تب‌ها بر اساس نوع فاکتور + int _getTabCountForType(InvoiceType? type) { + if (type == InvoiceType.waste || + type == InvoiceType.directConsumption || + type == InvoiceType.production) { + return 3; // اطلاعات فاکتور، کالاها و خدمات، تنظیمات + } + return 4; // همه تب‌ها + } + + + // بررسی اینکه آیا تب تراکنش‌ها باید نمایش داده شود + bool get _shouldShowTransactionsTab { + return _selectedInvoiceType != InvoiceType.waste && + _selectedInvoiceType != InvoiceType.directConsumption && + _selectedInvoiceType != InvoiceType.production; } @override @@ -77,20 +105,21 @@ class _NewInvoicePageState extends State with SingleTickerProvid toolbarHeight: 56, bottom: TabBar( controller: _tabController, - tabs: const [ - Tab( + tabs: [ + const Tab( icon: Icon(Icons.info_outline), text: 'اطلاعات فاکتور', ), - Tab( + const Tab( icon: Icon(Icons.inventory_2_outlined), text: 'کالاها و خدمات', ), - Tab( - icon: Icon(Icons.receipt_long_outlined), - text: 'تراکنش‌ها', - ), - Tab( + if (_shouldShowTransactionsTab) + const Tab( + icon: Icon(Icons.receipt_long_outlined), + text: 'تراکنش‌ها', + ), + const Tab( icon: Icon(Icons.settings_outlined), text: 'تنظیمات', ), @@ -104,8 +133,8 @@ class _NewInvoicePageState extends State with SingleTickerProvid _buildInvoiceInfoTab(), // تب کالاها و خدمات _buildProductsTab(), - // تب تراکنش‌ها - _buildTransactionsTab(), + // تب تراکنش‌ها (فقط اگر باید نمایش داده شود) + if (_shouldShowTransactionsTab) _buildTransactionsTab(), // تب تنظیمات _buildSettingsTab(), ], @@ -135,6 +164,12 @@ class _NewInvoicePageState extends State with SingleTickerProvid onTypeChanged: (type) { setState(() { _selectedInvoiceType = type; + // به‌روزرسانی TabController اگر تعداد تب‌ها تغییر کرده + final newTabCount = _getTabCountForType(type); + if (newTabCount != _tabController.length) { + _tabController.dispose(); + _tabController = TabController(length: newTabCount, vsync: this); + } }); }, isDraft: _isDraft, @@ -364,6 +399,12 @@ class _NewInvoicePageState extends State with SingleTickerProvid onTypeChanged: (type) { setState(() { _selectedInvoiceType = type; + // به‌روزرسانی TabController اگر تعداد تب‌ها تغییر کرده + final newTabCount = _getTabCountForType(type); + if (newTabCount != _tabController.length) { + _tabController.dispose(); + _tabController = TabController(length: newTabCount, vsync: this); + } }); }, isDraft: _isDraft, @@ -806,33 +847,45 @@ class _NewInvoicePageState extends State with SingleTickerProvid } Widget _buildProductsTab() { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.inventory_2_outlined, - size: 64, - color: Colors.grey, + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InvoiceLineItemsTable( + businessId: widget.businessId, + selectedCurrencyId: _selectedCurrencyId, + invoiceType: (_selectedInvoiceType?.value ?? 'sales'), + onChanged: (rows) { + setState(() { + _sumSubtotal = rows.fold(0, (acc, e) => acc + e.subtotal); + _sumDiscount = rows.fold(0, (acc, e) => acc + e.discountAmount); + _sumTax = rows.fold(0, (acc, e) => acc + e.taxAmount); + _sumTotal = rows.fold(0, (acc, e) => acc + e.total); + }); + }, + ), + const SizedBox(height: 12), + // نوار خلاصه جمع‌ها در والد (برای همگام‌سازی با سایر بخش‌ها) + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 16, + runSpacing: 8, + children: [ + Text('جمع مبلغ: ${formatWithThousands(_sumSubtotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge), + Text('جمع تخفیف: ${formatWithThousands(_sumDiscount, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge), + Text('جمع مالیات: ${formatWithThousands(_sumTax, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge), + Text('جمع کل: ${formatWithThousands(_sumTotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ), + ], ), - SizedBox(height: 16), - Text( - 'کالاها و خدمات', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - SizedBox(height: 8), - Text( - 'این بخش در آینده پیاده‌سازی خواهد شد', - style: TextStyle( - fontSize: 16, - color: Colors.grey, - ), - ), - ], + ), ), ); } diff --git a/hesabixUI/hesabix_ui/lib/services/product_service.dart b/hesabixUI/hesabix_ui/lib/services/product_service.dart index 2c91a8f..81c52cd 100644 --- a/hesabixUI/hesabix_ui/lib/services/product_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/product_service.dart @@ -78,6 +78,33 @@ class ProductService { } return false; } + + Future>> searchProducts({ + required int businessId, + String? searchQuery, + int limit = 20, + int skip = 0, + List>? filters, + List? searchFields, + }) async { + final body = { + 'take': limit, + 'skip': skip, + if (searchQuery != null && searchQuery.trim().isNotEmpty) 'search': searchQuery.trim(), + if (filters != null && filters.isNotEmpty) 'filters': filters, + if (searchFields != null && searchFields.isNotEmpty) 'searchFields': searchFields, + }; + final res = await _api.post>( + '/api/v1/products/business/$businessId/search', + data: body, + ); + final data = res.data?['data']; + final items = (data is Map) ? data['items'] : null; + if (items is List) { + return items.map>((e) => Map.from(e as Map)).toList(); + } + return const >[]; + } } diff --git a/hesabixUI/hesabix_ui/lib/services/tax_service.dart b/hesabixUI/hesabix_ui/lib/services/tax_service.dart index 07f340d..127c747 100644 --- a/hesabixUI/hesabix_ui/lib/services/tax_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/tax_service.dart @@ -5,10 +5,10 @@ class TaxService { final ApiClient _apiClient; TaxService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); - Future>> getTaxTypes({required int businessId}) async { + Future>> getTaxTypes({int? businessId}) async { try { final res = await _apiClient.get>( - '/api/v1/tax-types/business/$businessId', + '/api/v1/tax-types/', ); final data = res.data?['data']; if (data is List) { @@ -28,10 +28,10 @@ class TaxService { } } - Future>> getTaxUnits({required int businessId}) async { + Future>> getTaxUnits({int? businessId}) async { try { final res = await _apiClient.get>( - '/api/v1/tax-units/business/$businessId', + '/api/v1/tax-units/', ); final data = res.data?['data']; if (data is List) { diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart new file mode 100644 index 0000000..d80fc4d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart @@ -0,0 +1,1058 @@ +import 'package:flutter/material.dart'; +import '../../models/invoice_line_item.dart'; +import '../../utils/number_formatters.dart'; +import './product_combobox_widget.dart'; +// import './price_list_combobox_widget.dart'; +import '../../services/price_list_service.dart'; +import '../../core/api_client.dart'; + +class InvoiceLineItemsTable extends StatefulWidget { + final int businessId; + final int? selectedCurrencyId; // از تب ارز فاکتور + final ValueChanged>? onChanged; + final String invoiceType; // sales | purchase | sales_return | purchase_return | ... + + const InvoiceLineItemsTable({ + super.key, + required this.businessId, + this.selectedCurrencyId, + this.onChanged, + this.invoiceType = 'sales', + }); + + @override + State createState() => _InvoiceLineItemsTableState(); +} + +class _InvoiceLineItemsTableState extends State { + final List _rows = []; + final PriceListService _priceListService = PriceListService(apiClient: ApiClient()); + Map? _inlinePriceList; // کش لیست قیمت برای آیکون انتخاب قیمت + + void _notify() => widget.onChanged?.call(List.from(_rows)); + + void _updateRow(int index, InvoiceLineItem updated) { + setState(() { + _rows[index] = updated; + }); + _notify(); + } + + int? _toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value.toString()); + } + + num _toNum(dynamic value, {num fallback = 0}) { + if (value == null) return fallback; + if (value is num) return value; + return num.tryParse(value.toString()) ?? fallback; + } + + void _addRow() { + setState(() { + _rows.add(InvoiceLineItem( + taxRate: _getDefaultTaxRateForInvoiceType(), + )); + }); + _notify(); + } + + void _removeRow(int index) { + setState(() { + _rows.removeAt(index); + }); + _notify(); + } + + @override + void initState() { + super.initState(); + } + + @override + void didUpdateWidget(covariant InvoiceLineItemsTable oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedCurrencyId != widget.selectedCurrencyId) { + // ارز تغییر کرده: لازم است قیمت‌های بر اساس لیست قیمت مجدد ارزیابی شوند + _recalculateAllUnitPrices(); + // invalidate inline price list cache if currency changed + _inlinePriceList = null; + } + } + + // لیست قیمت سراسری حذف شده است؛ انتخاب قیمت از داخل سلول انجام می‌شود + + Future _recalculateAllUnitPrices() async { + // برای هر ردیف، اگر منبع قیمت «priceList» است سعی کن قیمت مناسب را بارگذاری/تبدیل کنی + for (int i = 0; i < _rows.length; i++) { + final it = _rows[i]; + final updated = await _resolveUnitPrice(it, preferManual: false); + setState(() => _rows[i] = updated); + } + _notify(); + } + + Future _resolveUnitPrice(InvoiceLineItem item, {bool preferManual = true}) async { + // اگر کاربر دستی وارد کرده، همان را نگه داریم (در مدل جدید، فیلد همیشه قابل ویرایش است) + if (preferManual && item.unitPriceSource == 'manual') return item; + + // تلاش بر اساس لیست قیمت (در مدل جدید از انتخاب داخل سلول استفاده می‌کنیم، + // این تابع همچنان fallback قیمت پایه را فراهم می‌کند.) + final currencyId = widget.selectedCurrencyId; + final pl = _inlinePriceList; // اگر از پیکر انتخاب قیمت ست شده باشد + if (pl != null && currencyId != null && item.productId != null) { + try { + final items = await _priceListService.listItems( + businessId: widget.businessId, + priceListId: (pl['id'] as int), + productId: item.productId, + currencyId: currencyId, + ); + num? priceOnMainUnit; + for (final pi in items) { + final unitId = pi['unit_id'] as int?; // ممکن است null باشد (یعنی بر واحد اصلی) + final price = (pi['price'] as num?) ?? 0; + if (unitId == null) { + // قیمت بر اساس واحد اصلی + priceOnMainUnit = price; + } + } + if (priceOnMainUnit != null) { + // تبدیل به واحد انتخابی + final converted = _convertFromMain(priceOnMainUnit, item); + return item.copyWith(unitPriceSource: 'priceList', unitPrice: converted); + } + } catch (_) { + // ignore + } + } + + // fallback: قیمت پایه محصول بر اساس نوع فاکتور (فرض: روی واحد اصلی) + final basePrice = _basePriceOfProduct(item); + final converted = _convertFromMain(basePrice, item); + return item.copyWith(unitPriceSource: 'base', unitPrice: converted); + } + + num _basePriceOfProduct(InvoiceLineItem item) { + // انتخاب قیمت پایه متناسب با نوع فاکتور: فروش/خرید + // قیمت‌های پایه فرضاً بر واحد اصلی هستند + if (widget.invoiceType == 'purchase' || widget.invoiceType == 'purchase_return') { + return item.basePurchasePriceMainUnit ?? 0; + } + return item.baseSalesPriceMainUnit ?? 0; + } + + num _convertFromMain(num priceOnMainUnit, InvoiceLineItem item) { + final rawFactor = item.unitConversionFactor; + final factor = (rawFactor == null || rawFactor <= 0) ? 1 : rawFactor; + final isMainSelected = (item.selectedUnit == item.mainUnit) || (item.selectedUnit == null); + if (isMainSelected) { + return priceOnMainUnit; + } + // در حالت انتخاب واحد فرعی: قیمت واحد باید در ضریب تبدیل ضرب شود + // تعریف: 1 main = factor * secondary => price(secondary) = price(main) * factor + return priceOnMainUnit * factor; + } + + num _defaultTaxRateFromProduct(Map p) { + if (widget.invoiceType == 'purchase' || widget.invoiceType == 'purchase_return') { + // برای فاکتور خرید و برگشت از خرید: از نرخ مالیات خرید استفاده کن + final isTaxable = p['is_purchase_taxable'] == true; + if (!isTaxable) return 0; + + final v = p['purchase_tax_rate']; + if (v is num && v > 0) return v; + // اگر محصول نرخ مالیات خرید نداشته باشد، از نرخ پیش‌فرض استفاده کن + return _getDefaultTaxRateForInvoiceType(); + } + + // برای فاکتور فروش و برگشت از فروش: از نرخ مالیات فروش استفاده کن + final isTaxable = p['is_sales_taxable'] == true; + if (!isTaxable) return 0; + + final v = p['sales_tax_rate']; + if (v is num && v > 0) return v; + // اگر محصول نرخ مالیات فروش نداشته باشد، از نرخ پیش‌فرض استفاده کن + return _getDefaultTaxRateForInvoiceType(); + } + + num _getDefaultTaxRateForInvoiceType() { + // نرخ پیش‌فرض مالیات بر اساس نوع فاکتور + switch (widget.invoiceType) { + case 'sales': + case 'sales_return': + return 9; // نرخ پیش‌فرض مالیات بر ارزش افزوده فروش (برای فروش و برگشت از فروش) + case 'purchase': + case 'purchase_return': + return 9; // نرخ پیش‌فرض مالیات بر ارزش افزوده خرید (برای خرید و برگشت از خرید) + default: + return 0; // سایر انواع فاکتور بدون مالیات + } + } + + String _unitTitle(InvoiceLineItem item, String? unit) { + if (unit == null) return ''; + return unit; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + ElevatedButton.icon( + onPressed: _addRow, + icon: const Icon(Icons.add), + label: const Text('افزودن ردیف'), + ), + const SizedBox(width: 12), + // لیست قیمت از بالای جدول حذف شد + const Spacer(), + // حالت فشرده به صورت پیش‌فرض و تنها حالت است + if (widget.selectedCurrencyId != null) + Chip(label: Text('ارز: ${widget.selectedCurrencyId}')), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + _buildHeader(context), + const Divider(height: 1), + if (_rows.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'ردیفی افزوده نشده است', + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ) + else + ..._rows.asMap().entries.map((e) => _buildCompactRow(context, e.key, e.value)).toList(), + const Divider(height: 1), + _buildFooter(context), + ], + ), + ), + ], + ); + } + + Widget _buildHeader(BuildContext context) { + final style = Theme.of(context).textTheme.labelLarge; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + _h('#', 36, style), + Expanded( + flex: 4, + child: Tooltip( + message: 'کالا/خدمت', + child: Text('کالا/خدمت', style: style), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: Tooltip( + message: 'تعداد/واحد', + child: Text('تعداد/واحد', style: style), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Tooltip( + message: 'قیمت واحد', + child: Text('قیمت واحد', style: style), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Tooltip( + message: 'مبلغ کل', + child: Text('مبلغ کل', style: style, textAlign: TextAlign.end), + ), + ), + ), + const SizedBox(width: 8), + const SizedBox(width: 40), + ], + ), + ); + } + + Widget _h(String text, double width, TextStyle? style, {bool alignEnd = false}) { + return SizedBox( + width: width, + child: Tooltip( + message: text, + child: Text(text, style: style, textAlign: alignEnd ? TextAlign.end : TextAlign.start), + ), + ); + } + + + Widget _buildCompactRow(BuildContext context, int index, InvoiceLineItem item) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + children: [ + // سطر اول: شماره، کالا/خدمت، تعداد+واحد، قیمت واحد، مبلغ کل، حذف + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 36, child: Text('${index + 1}')), + Flexible( + flex: 4, + child: SizedBox( + height: 36, + child: ProductComboboxWidget( + businessId: widget.businessId, + selectedProduct: item.productId != null + ? { + 'id': item.productId, + 'code': item.productCode, + 'name': item.productName, + 'main_unit': item.mainUnit, + 'secondary_unit': item.secondaryUnit, + 'unit_conversion_factor': item.unitConversionFactor, + } + : null, + onChanged: (p) async { + if (p == null) { + setState(() { + _rows[index] = item.copyWith( + productId: null, + productCode: null, + productName: null, + mainUnit: null, + secondaryUnit: null, + unitConversionFactor: null, + selectedUnit: null, + unitPriceSource: 'base', + unitPrice: 0, + ); + }); + _notify(); + return; + } + final mainUnit = p['main_unit']?.toString(); + final secondaryUnit = p['secondary_unit']?.toString(); + final updated = item.copyWith( + productId: _toInt(p['id']), + productCode: p['code']?.toString(), + productName: p['name']?.toString(), + mainUnit: mainUnit, + secondaryUnit: secondaryUnit, + unitConversionFactor: _toNum(p['unit_conversion_factor'], fallback: 1), + selectedUnit: mainUnit, + baseSalesPriceMainUnit: _toNum(p['base_sales_price']), + basePurchasePriceMainUnit: _toNum(p['base_purchase_price']), + taxRate: _defaultTaxRateFromProduct(p), + minOrderQty: _toInt(p['min_order_qty']), + trackInventory: p['track_inventory'] == true, + ); + final priced = await _resolveUnitPrice(updated, preferManual: false); + setState(() => _rows[index] = priced); + _notify(); + }, + ), + ), + ), + const SizedBox(width: 8), + Flexible( + flex: 2, + child: _buildQuantityWithUnitField(item, (qty) { + _updateRow(index, item.copyWith(quantity: qty)); + }, (unit) async { + final changed = item.copyWith(selectedUnit: unit); + final priced = await _resolveUnitPrice(changed, preferManual: item.unitPriceSource == 'manual'); + setState(() => _rows[index] = priced); + _notify(); + }), + ), + const SizedBox(width: 8), + Flexible( + flex: 3, + child: SizedBox( + height: 36, + child: Tooltip( + message: 'قیمت واحد (انتخاب از لیست یا ورود دستی)', + child: _UnitPriceCell( + businessId: widget.businessId, + invoiceType: widget.invoiceType, + currencyId: widget.selectedCurrencyId, + item: item, + onChanged: (src, price) { + final validatedPrice = price < 0 ? 0 : price; + _updateRow(index, item.copyWith(unitPriceSource: src, unitPrice: validatedPrice)); + }, + resolver: () => _resolveUnitPrice(item, preferManual: true), + unitTitleResolver: (u) => _unitTitle(item, u), + ), + ), + ), + ), + const SizedBox(width: 8), + Flexible( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Tooltip( + message: 'مبلغ کل این ردیف', + child: Text( + formatWithThousands(item.total, decimalPlaces: 0), + style: theme.textTheme.bodyMedium, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton(onPressed: () => _removeRow(index), icon: const Icon(Icons.delete, color: Colors.red)), + ], + ), + const SizedBox(height: 6), + // سطر دوم: شرح، تخفیف، مالیات + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 36), + Expanded( + child: SizedBox( + height: 36, + child: Tooltip( + message: 'شرح ردیف', + child: TextFormField( + initialValue: item.description ?? '', + onChanged: (v) { + _updateRow(index, item.copyWith(description: v)); + }, + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + hintText: 'شرح (اختیاری)' + ), + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 200, + child: SizedBox( + height: 36, + child: Tooltip( + message: 'تخفیف (نوع و مقدار)', + child: _DiscountCell( + value: item.discountValue, + type: item.discountType, + onChanged: (type, value) { + _updateRow(index, item.copyWith(discountType: type, discountValue: value)); + }, + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 200, + child: SizedBox( + height: 36, + child: Tooltip( + message: 'مالیات (درصد و مبلغ)', + child: _TaxCell( + rate: item.taxRate, + taxAmount: item.taxAmount, + onRateChanged: (r) { + _updateRow(index, item.copyWith(taxRate: r)); + }, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildQuantityWithUnitField(InvoiceLineItem item, ValueChanged onQuantityChanged, ValueChanged onUnitChanged) { + return _QuantityWithUnitField( + item: item, + onQuantityChanged: onQuantityChanged, + onUnitChanged: onUnitChanged, + onShowUnitSelector: () => _showUnitSelectorDialog(item, onUnitChanged), + ); + } + + + Widget _buildFooter(BuildContext context) { + final sumSubtotal = _rows.fold(0, (acc, e) => acc + e.subtotal); + final sumDiscount = _rows.fold(0, (acc, e) => acc + e.discountAmount); + final sumTax = _rows.fold(0, (acc, e) => acc + e.taxAmount); + final sumTotal = _rows.fold(0, (acc, e) => acc + e.total); + final style = Theme.of(context).textTheme.bodyLarge; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + const Spacer(), + SizedBox(width: 140, child: Text('جمع مبلغ: ${formatWithThousands(sumSubtotal, decimalPlaces: 0)}', style: style)), + SizedBox(width: 120, child: Text('جمع تخفیف: ${formatWithThousands(sumDiscount, decimalPlaces: 0)}', style: style)), + SizedBox(width: 120, child: Text('جمع مالیات: ${formatWithThousands(sumTax, decimalPlaces: 0)}', style: style)), + SizedBox(width: 140, child: Align(alignment: Alignment.centerRight, child: Text('جمع کل: ${formatWithThousands(sumTotal, decimalPlaces: 0)}', style: style))), + const SizedBox(width: 40), + ], + ), + ); + } + + void _showUnitSelectorDialog(InvoiceLineItem item, ValueChanged onChanged) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('انتخاب واحد'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.mainUnit?.isNotEmpty == true) + ListTile( + leading: const Icon(Icons.straighten), + title: Text(item.mainUnit!), + subtitle: const Text('واحد اصلی'), + trailing: (item.selectedUnit == item.mainUnit) ? const Icon(Icons.check, color: Colors.green) : null, + onTap: () { + onChanged(item.mainUnit); + Navigator.of(context).pop(); + }, + ), + if (item.secondaryUnit?.isNotEmpty == true) + ListTile( + leading: const Icon(Icons.inventory_2), + title: Text(item.secondaryUnit!), + subtitle: const Text('واحد فرعی'), + trailing: (item.selectedUnit == item.secondaryUnit) ? const Icon(Icons.check, color: Colors.green) : null, + onTap: () { + onChanged(item.secondaryUnit); + Navigator.of(context).pop(); + }, + ), + if (item.mainUnit?.isEmpty != false && item.secondaryUnit?.isEmpty != false) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('واحدی برای این محصول تعریف نشده است'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('انصراف'), + ), + ], + ), + ); + } + + // اعتبارسنجی تعداد مستقیماً داخل ویجت مقداردهی می‌شود؛ نیاز به تابع مجزا نیست +} + +class _DiscountCell extends StatefulWidget { + final String type; // percent | amount + final num value; + final void Function(String type, num value) onChanged; + + const _DiscountCell({ + required this.type, + required this.value, + required this.onChanged, + }); + + @override + State<_DiscountCell> createState() => _DiscountCellState(); +} + +class _DiscountCellState extends State<_DiscountCell> { + late String _type; + late TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _type = widget.type; + _ctrl = TextEditingController(text: widget.value.toString()); + } + + @override + void didUpdateWidget(covariant _DiscountCell oldWidget) { + super.didUpdateWidget(oldWidget); + // فقط اگر مقدار واقعاً تغییر کرده و کاربر در حال تایپ نیست، کنترلر را به‌روزرسانی کن + if (oldWidget.value != widget.value && !_ctrl.text.isNotEmpty) { + _ctrl.text = widget.value.toString(); + } + _type = widget.type; + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + String _typeLabel(String t) => t == 'percent' ? 'درصد' : 'مبلغ'; + return SizedBox( + height: 36, + child: TextFormField( + controller: _ctrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (v) => widget.onChanged(_type, num.tryParse(v) ?? 0), + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + suffix: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_type == 'percent') + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Text('%', style: theme.textTheme.bodySmall), + ) + else + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Text(_typeLabel(_type), style: theme.textTheme.bodySmall), + ), + PopupMenuButton( + tooltip: 'نوع تخفیف', + padding: EdgeInsets.zero, + itemBuilder: (c) => [ + PopupMenuItem( + value: 'amount', + child: const Text('مبلغ'), + ), + PopupMenuItem( + value: 'percent', + child: const Text('درصد'), + ), + ], + onSelected: (nv) { + setState(() => _type = nv); + widget.onChanged(nv, num.tryParse(_ctrl.text) ?? 0); + }, + child: const Icon(Icons.arrow_drop_down, size: 20), + ), + ], + ), + ), + ), + ); + } +} + +class _TaxCell extends StatefulWidget { + final num rate; // editable percent + final num taxAmount; // readonly + final ValueChanged onRateChanged; + + const _TaxCell({ + required this.rate, + required this.taxAmount, + required this.onRateChanged, + }); + + @override + State<_TaxCell> createState() => _TaxCellState(); +} + +class _TaxCellState extends State<_TaxCell> { + late TextEditingController _controller; + bool _isUserTyping = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.rate.toString()); + } + + @override + void didUpdateWidget(_TaxCell oldWidget) { + super.didUpdateWidget(oldWidget); + // فقط اگر کاربر در حال تایپ نیست، مقدار را به‌روزرسانی کن + if (oldWidget.rate != widget.rate && !_isUserTyping) { + _controller.text = widget.rate.toString(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 70, + child: TextFormField( + controller: _controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (v) { + _isUserTyping = true; + widget.onRateChanged(num.tryParse(v) ?? 0); + // بعد از یک فریم، فلگ را ریست کن + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _isUserTyping = false; + } + }); + }, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: '%'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(8), + ), + child: Text(formatWithThousands(widget.taxAmount, decimalPlaces: 0)), + ), + ), + ], + ); + } +} + +class _UnitPriceCell extends StatefulWidget { + final int businessId; + final String invoiceType; + final int? currencyId; + final InvoiceLineItem item; + final void Function(String source, num price) onChanged; + final Future Function() resolver; + final String Function(String? unit) unitTitleResolver; + + const _UnitPriceCell({ + required this.businessId, + required this.invoiceType, + required this.currencyId, + required this.item, + required this.onChanged, + required this.resolver, + required this.unitTitleResolver, + }); + + @override + State<_UnitPriceCell> createState() => _UnitPriceCellState(); +} + +class _UnitPriceCellState extends State<_UnitPriceCell> { + late TextEditingController _ctrl; + bool _loading = false; + final PriceListService _pls = PriceListService(apiClient: ApiClient()); + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.item.unitPrice.toString()); + _focusNode = FocusNode(); + } + + @override + void didUpdateWidget(covariant _UnitPriceCell oldWidget) { + super.didUpdateWidget(oldWidget); + // اگر مقدار از بیرون تغییر کرد و فیلد در فوکوس نیست، متن را همگام کن + if ((oldWidget.item.unitPrice != widget.item.unitPrice || oldWidget.item.unitPriceSource != widget.item.unitPriceSource) && + !_focusNode.hasFocus) { + _ctrl.text = widget.item.unitPrice.toString(); + } + } + + @override + void dispose() { + _ctrl.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + // حذف شد: _applySource (مدل جدید نیازی ندارد) + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextFormField( + controller: _ctrl, + focusNode: _focusNode, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (v) { + final cleaned = v.replaceAll(',', ''); + final price = num.tryParse(cleaned) ?? 0; + widget.onChanged('manual', price < 0 ? 0 : price); + }, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + suffixIcon: _loading + ? const Padding(padding: EdgeInsets.all(8), child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))) + : IconButton( + tooltip: 'انتخاب از لیست قیمت', + icon: const Icon(Icons.list_alt_outlined), + onPressed: () => _openPricePicker(context), + ), + ), + ), + ), + ], + ); + } + + Future _openPricePicker(BuildContext context) async { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: FutureBuilder>>( + future: _fetchPriceOptions(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox(height: 200, child: Center(child: CircularProgressIndicator())); + } + final options = snapshot.data ?? const >[]; + if (options.isEmpty) { + return const SizedBox(height: 200, child: Center(child: Text('قیمتی برای نمایش یافت نشد'))); + } + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.separated( + itemCount: options.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (c, i) { + final opt = options[i]; + final price = (opt['price'] as num?) ?? 0; + final label = (opt['label'] as String?) ?? ''; + return ListTile( + leading: const Icon(Icons.sell_outlined), + title: Text(formatWithThousands(price, decimalPlaces: 0)), + subtitle: label.isNotEmpty ? Text(label) : null, + onTap: () { + _ctrl.text = price.toString(); + widget.onChanged('manual', price); + Navigator.pop(ctx); + }, + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + } + + Future>> _fetchPriceOptions() async { + final List> result = >[]; + + // گزینه ۱: قیمت پایه (بر اساس نوع فاکتور و تبدیل واحد) + try { + final resolved = await widget.resolver(); + result.add({ + 'label': 'قیمت پایه تخمینی', + 'price': resolved.unitPrice, + }); + } catch (_) {} + + // گزینه‌های لیست قیمت (در صورت داشتن ارز و محصول) + final productId = widget.item.productId; + final currencyId = widget.currencyId; + + if (productId != null && currencyId != null) { + try { + final res = await _pls.listPriceLists(businessId: widget.businessId, limit: 50); + final lists = (res['items'] as List?)?.cast().map((e) => Map.from(e as Map)).toList() ?? const >[]; + + for (final pl in lists) { + final plId = pl['id'] as int?; + final plName = pl['name']?.toString() ?? 'لیست قیمت'; + if (plId == null) continue; + + try { + final items = await _pls.listItems( + businessId: widget.businessId, + priceListId: plId, + productId: productId, + currencyId: currencyId, + ); + + for (final pi in items) { + final priceValue = pi['price']; + num? price; + + // تبدیل قیمت از String به num + if (priceValue is num) { + price = priceValue; + } else if (priceValue is String) { + price = num.tryParse(priceValue); + } else { + price = 0; + } + + if (price == null || price <= 0) continue; + final unitLabel = 'واحد اصلی'; // چون unit_id null است، یعنی واحد اصلی + result.add({ + 'label': '$plName - $unitLabel', + 'price': price, + }); + } + } catch (_) { + // ignore per list errors + } + } + } catch (_) { + // ignore + } + } + // مرتب‌سازی: نزدیک‌ترین قیمت به مقدار فعلی در ابتدای لیست + final current = num.tryParse(_ctrl.text) ?? widget.item.unitPrice; + result.sort((a, b) { + final pa = (a['price'] as num?) ?? 0; + final pb = (b['price'] as num?) ?? 0; + return (pa - current).abs().compareTo((pb - current).abs()); + }); + return result; + } + + // حذف شد: _unitLabelFor (با unitTitleResolver جایگزین شده) +} + +class _QuantityWithUnitField extends StatefulWidget { + final InvoiceLineItem item; + final ValueChanged onQuantityChanged; + final ValueChanged onUnitChanged; + final VoidCallback onShowUnitSelector; + + const _QuantityWithUnitField({ + required this.item, + required this.onQuantityChanged, + required this.onUnitChanged, + required this.onShowUnitSelector, + }); + + @override + State<_QuantityWithUnitField> createState() => _QuantityWithUnitFieldState(); +} + +class _QuantityWithUnitFieldState extends State<_QuantityWithUnitField> { + late TextEditingController _controller; + String? _currentUnit; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _currentUnit = widget.item.selectedUnit ?? widget.item.mainUnit; + _updateController(); + } + + @override + void didUpdateWidget(_QuantityWithUnitField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.item.quantity != widget.item.quantity || + oldWidget.item.selectedUnit != widget.item.selectedUnit) { + _currentUnit = widget.item.selectedUnit ?? widget.item.mainUnit; + _updateController(); + } + } + + void _updateController() { + // فقط مقدار عددی را در فیلد نگه می‌داریم؛ واحد به‌صورت لیبل در suffix نمایش داده می‌شود + // فقط اگر کاربر در حال تایپ نیست، کنترلر را به‌روزرسانی کن + if (!_controller.text.isNotEmpty) { + _controller.text = widget.item.quantity.toString(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: TextFormField( + controller: _controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (v) { + // فقط عدد ورودی کاربر را می‌خوانیم + final cleaned = v.replaceAll(',', ''); + final q = num.tryParse(cleaned) ?? 0; + widget.onQuantityChanged(q); + }, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + // نمایش واحد به صورت لیبل غیرقابل ویرایش در کنار دکمه انتخاب + suffix: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_currentUnit != null) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Text( + _currentUnit!, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + IconButton( + icon: const Icon(Icons.arrow_drop_down, size: 20), + onPressed: widget.onShowUnitSelector, + tooltip: 'انتخاب واحد', + ), + ], + ), + ), + ), + ); + } +} + + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart new file mode 100644 index 0000000..945db94 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/price_list_combobox_widget.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import '../../services/price_list_service.dart'; +import '../../core/api_client.dart'; + +class PriceListComboboxWidget extends StatefulWidget { + final int businessId; + final int? selectedPriceListId; + final ValueChanged?> onChanged; + final String label; + final String hintText; + + const PriceListComboboxWidget({ + super.key, + required this.businessId, + required this.onChanged, + this.selectedPriceListId, + this.label = 'لیست قیمت', + this.hintText = 'انتخاب لیست قیمت', + }); + + @override + State createState() => _PriceListComboboxWidgetState(); +} + +class _PriceListComboboxWidgetState extends State { + final PriceListService _service = PriceListService(apiClient: ApiClient()); + bool _loading = false; + List> _items = const >[]; + Map? _selected; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final res = await _service.listPriceLists(businessId: widget.businessId, limit: 50); + final items = (res['items'] as List?)?.cast().map((e) => Map.from(e as Map)).toList() ?? const >[]; + Map? selected; + if (widget.selectedPriceListId != null) { + selected = items.firstWhere((e) => e['id'] == widget.selectedPriceListId, orElse: () => {}); + if (selected.isEmpty) selected = null; + } + setState(() { + _items = items; + _selected = selected ?? (items.isNotEmpty ? items.first : null); + }); + widget.onChanged(_selected); + } catch (_) { + setState(() { + _items = const >[]; + _selected = null; + }); + widget.onChanged(null); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 220, + child: Tooltip( + message: _selected != null ? (_selected!['name']?.toString() ?? '') : widget.hintText, + child: DropdownButtonFormField( + value: _selected != null ? (_selected!['id'] as int) : null, + isExpanded: true, + items: _items + .map((e) => DropdownMenuItem( + value: e['id'] as int, + child: Tooltip(message: e['name']?.toString() ?? '', child: Text(e['name']?.toString() ?? '')), + )) + .toList(), + onChanged: _loading + ? null + : (val) { + final sel = _items.firstWhere((e) => e['id'] == val, orElse: () => {}); + setState(() => _selected = sel.isEmpty ? null : sel); + widget.onChanged(_selected); + }, + decoration: InputDecoration( + isDense: true, + labelText: widget.label, + hintText: widget.hintText, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + ), + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart new file mode 100644 index 0000000..6402344 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../services/product_service.dart'; +import '../../core/api_client.dart'; + +class ProductComboboxWidget extends StatefulWidget { + final int businessId; + final Map? selectedProduct; + final ValueChanged?> onChanged; + final String label; + final String hintText; + + const ProductComboboxWidget({ + super.key, + required this.businessId, + required this.onChanged, + this.selectedProduct, + this.label = 'کالا/خدمت', + this.hintText = 'جست‌وجو و انتخاب کالا/خدمت', + }); + + @override + State createState() => _ProductComboboxWidgetState(); +} + +class _ProductComboboxWidgetState extends State { + final ProductService _service = ProductService(apiClient: ApiClient()); + final TextEditingController _searchCtrl = TextEditingController(); + Timer? _debounce; + bool _loading = false; + List> _items = const >[]; + + @override + void initState() { + super.initState(); + _searchCtrl.text = widget.selectedProduct != null + ? ((widget.selectedProduct!['code']?.toString() ?? '') + ' - ' + (widget.selectedProduct!['name']?.toString() ?? '')) + : ''; + _loadRecent(); + } + + @override + void dispose() { + _debounce?.cancel(); + _searchCtrl.dispose(); + super.dispose(); + } + + Future _loadRecent() async { + setState(() => _loading = true); + try { + final items = await _service.searchProducts( + businessId: widget.businessId, + searchQuery: null, + limit: 10, + searchFields: const ['code', 'name'], + ); + if (!mounted) return; + setState(() => _items = items); + } catch (_) { + if (!mounted) return; + setState(() => _items = const >[]); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _onQueryChanged(String q) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () => _performSearch(q.trim())); + } + + Future _performSearch(String q) async { + if (q.isEmpty) { + await _loadRecent(); + return; + } + setState(() => _loading = true); + try { + final items = await _service.searchProducts( + businessId: widget.businessId, + searchQuery: q, + limit: 20, + searchFields: const ['code', 'name'], + ); + if (!mounted) return; + setState(() => _items = items); + } catch (_) { + if (!mounted) return; + setState(() => _items = const >[]); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _select(Map? item) { + if (item == null) { + _searchCtrl.clear(); + widget.onChanged(null); + return; + } + final code = item['code']?.toString() ?? ''; + final name = item['name']?.toString() ?? ''; + _searchCtrl.text = code.isNotEmpty ? '$code - $name' : name; + widget.onChanged(item); + } + + void _openPicker() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + final theme = Theme.of(ctx); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + const Spacer(), + IconButton(onPressed: () => Navigator.pop(ctx), icon: const Icon(Icons.close)), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _searchCtrl, + decoration: InputDecoration( + hintText: widget.hintText, + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + onChanged: _onQueryChanged, + ), + const SizedBox(height: 12), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (c, i) { + final it = _items[i]; + final code = it['code']?.toString() ?? ''; + final name = it['name']?.toString() ?? ''; + final itemType = it['item_type']?.toString() ?? ''; + return ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(code.isNotEmpty ? '$code - $name' : name), + subtitle: itemType.isNotEmpty ? Text(itemType) : null, + onTap: () { + _select(it); + Navigator.pop(ctx); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final display = widget.selectedProduct != null + ? (((widget.selectedProduct!['code']?.toString() ?? '').isNotEmpty) + ? '${widget.selectedProduct!['code']} - ${widget.selectedProduct!['name']}' + : (widget.selectedProduct!['name']?.toString() ?? '')) + : widget.hintText; + + return InkWell( + onTap: _openPicker, + child: Tooltip( + message: display, + waitDuration: const Duration(milliseconds: 600), + preferBelow: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(8), + color: colorScheme.surface, + ), + child: Row( + children: [ + Icon(Icons.inventory_2_outlined, color: colorScheme.primary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500, fontSize: 13.5), + ), + ), + Icon(Icons.arrow_drop_down, color: colorScheme.onSurface.withValues(alpha: 0.6), size: 20), + ], + ), + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart index 8253de1..863fc84 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart @@ -131,7 +131,6 @@ class _ProductFormDialogState extends State { onChanged: _controller.updateFormData, categories: _controller.categories, attributes: _controller.attributes, - units: _controller.units, ), ); } @@ -142,7 +141,6 @@ class _ProductFormDialogState extends State { child: ProductPricingInventorySection( formData: _controller.formData, onChanged: _controller.updateFormData, - units: _controller.units, priceLists: _controller.priceLists, currencies: _controller.currencies, draftPriceItems: _controller.draftPriceItems, diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart index 660b750..ab1f665 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart @@ -11,7 +11,6 @@ class ProductBasicInfoSection extends StatelessWidget { final ValueChanged onChanged; final List> categories; final List> attributes; - final List> units; const ProductBasicInfoSection({ super.key, @@ -20,7 +19,6 @@ class ProductBasicInfoSection extends StatelessWidget { required this.onChanged, required this.categories, required this.attributes, - required this.units, }); @override @@ -161,44 +159,55 @@ class ProductBasicInfoSection extends StatelessWidget { } Widget _buildUnitsSection(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(AppLocalizations.of(context).unitsTitle, style: Theme.of(context).textTheme.titleSmall), + Text(t.unitsTitle, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), Row( children: [ - Expanded(child: _buildUnitTextField( - context: context, - label: AppLocalizations.of(context).mainUnit, - isRequired: true, - initialText: _unitNameById(formData.mainUnitId) ?? 'عدد', - onChanged: (text) { - final mappedId = _findUnitIdByTitle(text); - _updateFormData(formData.copyWith(mainUnitId: mappedId)); - }, - )), + Expanded( + child: TextFormField( + initialValue: formData.mainUnit ?? '', + decoration: InputDecoration(labelText: t.mainUnit), + validator: (v) => (v == null || v.trim().isEmpty) ? t.required : null, + onChanged: (text) { + _updateFormData(formData.copyWith( + mainUnit: text.trim().isEmpty ? null : text.trim(), + )); + }, + ), + ), const SizedBox(width: 12), - Expanded(child: _buildUnitTextField( - context: context, - label: AppLocalizations.of(context).secondaryUnit, - isRequired: false, - initialText: _unitNameById(formData.secondaryUnitId) ?? '', - onChanged: (text) { - final mappedId = _findUnitIdByTitle(text); - _updateFormData(formData.copyWith(secondaryUnitId: mappedId)); - }, - )), + Expanded( + child: TextFormField( + initialValue: formData.secondaryUnit ?? '', + decoration: InputDecoration(labelText: t.secondaryUnit), + onChanged: (text) { + _updateFormData(formData.copyWith( + secondaryUnit: text.trim().isEmpty ? null : text.trim(), + )); + }, + ), + ), const SizedBox(width: 12), Expanded( child: TextFormField( initialValue: formData.unitConversionFactor.toString(), - decoration: InputDecoration(labelText: AppLocalizations.of(context).unitConversionFactor), + decoration: InputDecoration(labelText: t.unitConversionFactor), keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\\d*\\.?\\d{0,2}$')), + FilteringTextInputFormatter.allow(RegExp(r'[0-9\.]')), ], - validator: ProductFormValidator.validateConversionFactor, + validator: (value) { + // اگر واحد فرعی انتخاب شده، ضریب اجباری و > 0 است + final hasSecondary = formData.secondaryUnit?.trim().isNotEmpty == true; + if (hasSecondary && (value == null || value.trim().isEmpty)) { + return t.required; + } + return ProductFormValidator.validateConversionFactor(value); + }, onChanged: (value) => _updateFormData(formData.copyWith(unitConversionFactor: num.tryParse(value))), ), ), @@ -208,44 +217,6 @@ class ProductBasicInfoSection extends StatelessWidget { ); } - Widget _buildUnitTextField({ - required BuildContext context, - required String label, - required bool isRequired, - required String initialText, - required ValueChanged onChanged, - }) { - return TextFormField( - initialValue: initialText, - decoration: InputDecoration(labelText: label), - keyboardType: TextInputType.text, - validator: isRequired ? (v) => (v == null || v.trim().isEmpty) ? '${AppLocalizations.of(context).required}' : 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) { final t = AppLocalizations.of(context); diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart index 5048cd0..53e946a 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart @@ -8,7 +8,6 @@ import '../../../utils/product_form_validator.dart'; class ProductPricingInventorySection extends StatelessWidget { final ProductFormData formData; final ValueChanged onChanged; - final List> units; final List> priceLists; final List> currencies; final List> draftPriceItems; @@ -19,7 +18,6 @@ class ProductPricingInventorySection extends StatelessWidget { super.key, required this.formData, required this.onChanged, - required this.units, required this.priceLists, required this.currencies, required this.draftPriceItems, diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart index d661680..32655c5 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart @@ -4,7 +4,7 @@ import 'package:flutter/services.dart'; import '../../../models/product_form_data.dart'; import '../../../utils/product_form_validator.dart'; -class ProductTaxSection extends StatelessWidget { +class ProductTaxSection extends StatefulWidget { final ProductFormData formData; final ValueChanged onChanged; final List> taxTypes; @@ -18,6 +18,12 @@ class ProductTaxSection extends StatelessWidget { required this.taxUnits, }); + @override + State createState() => _ProductTaxSectionState(); +} + +class _ProductTaxSectionState extends State { + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); @@ -66,10 +72,10 @@ class ProductTaxSection extends StatelessWidget { Widget _buildTaxCodeField(BuildContext context) { final t = AppLocalizations.of(context); return TextFormField( - initialValue: formData.taxCode, + initialValue: widget.formData.taxCode, decoration: InputDecoration(labelText: t.taxCode), - onChanged: (value) => _updateFormData( - formData.copyWith(taxCode: value.trim().isEmpty ? null : value), + onChanged: (value) => widget.onChanged( + widget.formData.copyWith(taxCode: value.trim().isEmpty ? null : value), ), ); } @@ -80,21 +86,21 @@ class ProductTaxSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( - value: formData.isSalesTaxable, - onChanged: (value) => _updateFormData(formData.copyWith(isSalesTaxable: value)), + value: widget.formData.isSalesTaxable, + onChanged: (value) => widget.onChanged(widget.formData.copyWith(isSalesTaxable: value)), title: Text(t.isSalesTaxable), ), - if (formData.isSalesTaxable) ...[ + if (widget.formData.isSalesTaxable) ...[ const SizedBox(height: 16), TextFormField( - initialValue: formData.salesTaxRate?.toString(), + initialValue: widget.formData.salesTaxRate?.toString(), decoration: InputDecoration(labelText: t.salesTaxRate), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: t.salesTaxRate), - onChanged: (value) => _updateFormData(formData.copyWith(salesTaxRate: num.tryParse(value))), + onChanged: (value) => widget.onChanged(widget.formData.copyWith(salesTaxRate: num.tryParse(value))), ), ], ], @@ -107,21 +113,21 @@ class ProductTaxSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( - value: formData.isPurchaseTaxable, - onChanged: (value) => _updateFormData(formData.copyWith(isPurchaseTaxable: value)), + value: widget.formData.isPurchaseTaxable, + onChanged: (value) => widget.onChanged(widget.formData.copyWith(isPurchaseTaxable: value)), title: Text(t.isPurchaseTaxable), ), - if (formData.isPurchaseTaxable) ...[ + if (widget.formData.isPurchaseTaxable) ...[ const SizedBox(height: 16), TextFormField( - initialValue: formData.purchaseTaxRate?.toString(), + initialValue: widget.formData.purchaseTaxRate?.toString(), decoration: InputDecoration(labelText: t.purchaseTaxRate), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: t.purchaseTaxRate), - onChanged: (value) => _updateFormData(formData.copyWith(purchaseTaxRate: num.tryParse(value))), + onChanged: (value) => widget.onChanged(widget.formData.copyWith(purchaseTaxRate: num.tryParse(value))), ), ], ], @@ -129,52 +135,62 @@ class ProductTaxSection extends StatelessWidget { } Widget _buildTaxTypeDropdown(BuildContext context) { - if (taxTypes.isNotEmpty) { + if (widget.taxTypes.isNotEmpty) { final t = AppLocalizations.of(context); return DropdownButtonFormField( - value: formData.taxTypeId, - items: taxTypes - .map((taxType) => DropdownMenuItem( - value: (taxType['id'] as num).toInt(), - child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()), - )) - .toList(), - onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: value)), + initialValue: widget.formData.taxTypeId, + items: [ + DropdownMenuItem( + value: null, + child: Text('انتخاب ${t.taxType}'), + ), + ...widget.taxTypes + .map((taxType) => DropdownMenuItem( + value: (taxType['id'] as num).toInt(), + child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()), + )), + ], + onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxTypeId: value)), decoration: InputDecoration(labelText: t.taxType), ); } else { final t = AppLocalizations.of(context); return TextFormField( - initialValue: formData.taxTypeId?.toString(), - decoration: InputDecoration(labelText: t.taxTypeId), + initialValue: widget.formData.taxTypeId?.toString(), + decoration: InputDecoration(labelText: t.taxType), keyboardType: TextInputType.number, - onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: int.tryParse(value))), + onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxTypeId: int.tryParse(value))), ); } } Widget _buildTaxUnitDropdown(BuildContext context) { - final List> effectiveTaxUnits = taxUnits.isNotEmpty ? taxUnits : _fallbackTaxUnits(); + final List> effectiveTaxUnits = widget.taxUnits.isNotEmpty ? widget.taxUnits : _fallbackTaxUnits(); if (effectiveTaxUnits.isNotEmpty) { final t = AppLocalizations.of(context); return DropdownButtonFormField( - value: formData.taxUnitId, - items: effectiveTaxUnits - .map((taxUnit) => DropdownMenuItem( - value: (taxUnit['id'] as num).toInt(), - child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()), - )) - .toList(), - onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: value)), + initialValue: widget.formData.taxUnitId, + items: [ + DropdownMenuItem( + value: null, + child: Text('انتخاب ${t.taxUnit}'), + ), + ...effectiveTaxUnits + .map((taxUnit) => DropdownMenuItem( + value: (taxUnit['id'] as num).toInt(), + child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()), + )), + ], + onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxUnitId: value)), decoration: InputDecoration(labelText: t.taxUnit), ); } else { final t = AppLocalizations.of(context); return TextFormField( - initialValue: formData.taxUnitId?.toString(), + initialValue: widget.formData.taxUnitId?.toString(), decoration: InputDecoration(labelText: t.taxUnitId), keyboardType: TextInputType.number, - onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: int.tryParse(value))), + onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxUnitId: int.tryParse(value))), ); } } @@ -201,7 +217,4 @@ class ProductTaxSection extends StatelessWidget { }); } - void _updateFormData(ProductFormData newData) { - onChanged(newData); - } }