progress in invoice and products

This commit is contained in:
Hesabix 2025-10-07 00:46:29 +03:30
parent 0edff7d020
commit 7f6a78f642
36 changed files with 2400 additions and 612 deletions

View file

@ -242,8 +242,8 @@ async def export_products_excel(
("category_id", "دسته"), ("category_id", "دسته"),
("base_sales_price", "قیمت فروش"), ("base_sales_price", "قیمت فروش"),
("base_purchase_price", "قیمت خرید"), ("base_purchase_price", "قیمت خرید"),
("main_unit_id", "واحد اصلی"), ("main_unit", "واحد اصلی"),
("secondary_unit_id", "واحد فرعی"), ("secondary_unit", "واحد فرعی"),
("track_inventory", "کنترل موجودی"), ("track_inventory", "کنترل موجودی"),
("created_at_formatted", "ایجاد"), ("created_at_formatted", "ایجاد"),
] ]
@ -358,7 +358,7 @@ async def download_products_import_template(
headers = [ headers = [
"code","name","item_type","description","category_id", "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", "base_sales_price","base_purchase_price","track_inventory",
"reorder_point","min_order_qty","lead_time_days", "reorder_point","min_order_qty","lead_time_days",
"is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate", "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']: for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']:
if k in item: if k in item:
item[k] = _parse_decimal(item.get(k)) 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: if k in item:
item[k] = _parse_int(item.get(k)) item[k] = _parse_int(item.get(k))
for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']: for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
@ -673,8 +673,8 @@ async def export_products_pdf(
("category_id", "دسته"), ("category_id", "دسته"),
("base_sales_price", "قیمت فروش"), ("base_sales_price", "قیمت فروش"),
("base_purchase_price", "قیمت خرید"), ("base_purchase_price", "قیمت خرید"),
("main_unit_id", "واحد اصلی"), ("main_unit", "واحد اصلی"),
("secondary_unit_id", "واحد فرعی"), ("secondary_unit", "واحد فرعی"),
("track_inventory", "کنترل موجودی"), ("track_inventory", "کنترل موجودی"),
("created_at_formatted", "ایجاد"), ("created_at_formatted", "ایجاد"),
] ]

View file

@ -18,8 +18,8 @@ class ProductCreateRequest(BaseModel):
description: Optional[str] = Field(default=None, max_length=2000) description: Optional[str] = Field(default=None, max_length=2000)
category_id: Optional[int] = None category_id: Optional[int] = None
main_unit_id: Optional[int] = None main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش")
secondary_unit_id: Optional[int] = None secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش")
unit_conversion_factor: Optional[Decimal] = None unit_conversion_factor: Optional[Decimal] = None
base_sales_price: 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) description: Optional[str] = Field(default=None, max_length=2000)
category_id: Optional[int] = None category_id: Optional[int] = None
main_unit_id: Optional[int] = None main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش")
secondary_unit_id: Optional[int] = None secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش")
unit_conversion_factor: Optional[Decimal] = None unit_conversion_factor: Optional[Decimal] = None
base_sales_price: Optional[Decimal] = None base_sales_price: Optional[Decimal] = None
@ -83,8 +83,8 @@ class ProductResponse(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
category_id: Optional[int] = None category_id: Optional[int] = None
main_unit_id: Optional[int] = None main_unit: Optional[str] = None
secondary_unit_id: Optional[int] = None secondary_unit: Optional[str] = None
unit_conversion_factor: Optional[Decimal] = None unit_conversion_factor: Optional[Decimal] = None
base_sales_price: Optional[Decimal] = None base_sales_price: Optional[Decimal] = None
base_sales_note: Optional[str] = None base_sales_note: Optional[str] = None

View file

@ -1,49 +1,59 @@
from typing import Dict, Any, List from typing import Dict, Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from adapters.api.v1.schemas import SuccessResponse 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.responses import success_response
from app.core.auth_dependency import get_current_user, AuthContext from sqlalchemy.orm import Session
from app.core.permissions import require_business_access from adapters.db.models.tax_type import TaxType
from sqlalchemy.orm import Session # noqa: F401
router = APIRouter(prefix="/tax-types", tags=["tax-types"]) router = APIRouter(prefix="/tax-types", tags=["tax-types"])
def _static_tax_types() -> List[Dict[str, Any]]: @router.get("/",
titles = [
"دارو",
"دخانیات",
"موبایل",
"لوازم خانگی برقی",
"قطعات مصرفی و یدکی وسایل نقلیه",
"فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
"طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
"منسوجات و پوشاک",
"اسباب بازی",
"دام زنده، گوشت سفید و قرمز",
"محصولات اساسی کشاورزی",
"سایر کالا ها",
]
return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
@router.get(
"/business/{business_id}",
summary="لیست نوع‌های مالیات", summary="لیست نوع‌های مالیات",
description="دریافت لیست نوع‌های مالیات (ثابت)", description="دریافت لیست تمام نوع‌های مالیات استاندارد",
response_model=SuccessResponse, 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( def list_tax_types(
request: Request, request: Request,
business_id: int, db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
# Currently returns a static list; later can be sourced from DB if needed """دریافت لیست تمام نوع‌های مالیات استاندارد"""
items = _static_tax_types()
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) return success_response(items, request)

View file

@ -1,55 +1,19 @@
from fastapi import APIRouter, Depends, Request, HTTPException from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import Dict, Any
from decimal import Decimal
from adapters.db.session import get_db from adapters.db.session import get_db
from adapters.db.models.tax_unit import TaxUnit from adapters.db.models.tax_unit import TaxUnit
from adapters.api.v1.schemas import SuccessResponse from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields 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 pydantic import BaseModel, Field
router = APIRouter(prefix="/tax-units", tags=["tax-units"]) router = APIRouter(prefix="/tax-units", tags=["tax-units"])
alias_router = APIRouter(prefix="/units", tags=["units"])
class TaxUnitCreateRequest(BaseModel): @router.get("/",
name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی") summary="لیست واحدهای مالیاتی",
code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی") description="دریافت لیست تمام واحدهای مالیاتی استاندارد",
description: Optional[str] = Field(default=None, description="توضیحات")
tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
is_active: bool = Field(default=True, description="وضعیت فعال/غیرفعال")
class TaxUnitUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام واحد مالیاتی")
code: Optional[str] = Field(default=None, min_length=1, max_length=64, description="کد واحد مالیاتی")
description: Optional[str] = Field(default=None, description="توضیحات")
tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال/غیرفعال")
class TaxUnitResponse(BaseModel):
id: int
business_id: int
name: str
code: str
description: Optional[str] = None
tax_rate: Optional[Decimal] = None
is_active: bool
created_at: str
updated_at: str
class Config:
from_attributes = True
@router.get("/business/{business_id}",
summary="لیست واحدهای مالیاتی کسب‌وکار",
description="دریافت لیست واحدهای مالیاتی یک کسب‌وکار",
response_model=SuccessResponse, response_model=SuccessResponse,
responses={ responses={
200: { 200: {
@ -62,12 +26,9 @@ class TaxUnitResponse(BaseModel):
"data": [ "data": [
{ {
"id": 1, "id": 1,
"business_id": 1, "name": "کیلوگرم",
"name": "مالیات بر ارزش افزوده", "code": "کیلوگرم",
"code": "VAT", "description": None,
"description": "مالیات بر ارزش افزوده 9 درصد",
"tax_rate": 9.0,
"is_active": True,
"created_at": "2024-01-01T00:00:00Z", "created_at": "2024-01-01T00:00:00Z",
"updated_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}") def list_tax_units(
@require_business_access()
def get_tax_units(
request: Request, request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
) -> dict: ) -> Dict[str, Any]:
"""دریافت لیست واحدهای مالیاتی یک کسب‌وکار""" """دریافت لیست تمام واحدهای مالیاتی استاندارد"""
# Query tax units for the business # Query all tax units (they are global now)
tax_units = db.query(TaxUnit).filter( tax_units = db.query(TaxUnit).order_by(TaxUnit.name).all()
TaxUnit.business_id == business_id
).order_by(TaxUnit.name).all()
# Convert to response format # Convert to response format
tax_unit_dicts = [] tax_unit_dicts = []
for tax_unit in tax_units: for tax_unit in tax_units:
tax_unit_dict = { tax_unit_dict = {
"id": tax_unit.id, "id": tax_unit.id,
"business_id": tax_unit.business_id,
"name": tax_unit.name, "name": tax_unit.name,
"code": tax_unit.code, "code": tax_unit.code,
"description": tax_unit.description, "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(), "created_at": tax_unit.created_at.isoformat(),
"updated_at": tax_unit.updated_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) 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)

View file

@ -36,5 +36,6 @@ from .product import Product # noqa: F401
from .price_list import PriceList, PriceItem # noqa: F401 from .price_list import PriceList, PriceItem # noqa: F401
from .product_attribute_link import ProductAttributeLink # noqa: F401 from .product_attribute_link import ProductAttributeLink # noqa: F401
from .tax_unit import TaxUnit # noqa: F401 from .tax_unit import TaxUnit # noqa: F401
from .tax_type import TaxType # noqa: F401
from .bank_account import BankAccount # noqa: F401 from .bank_account import BankAccount # noqa: F401
from .petty_cash import PettyCash # noqa: F401 from .petty_cash import PettyCash # noqa: F401

View file

@ -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) 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) main_unit: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="واحد اصلی شمارش")
secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) 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) unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True)
# قیمت‌های پایه (نمایشی) # قیمت‌های پایه (نمایشی)

View file

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

View file

@ -1,5 +1,5 @@
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base from adapters.db.session import Base
@ -14,11 +14,8 @@ class TaxUnit(Base):
__tablename__ = "tax_units" __tablename__ = "tax_units"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 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="نام واحد مالیاتی") name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی") code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
description: Mapped[str | None] = mapped_column(Text, nullable=True, 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) 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) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -78,8 +78,8 @@ class ProductRepository(BaseRepository[Product]):
"name": p.name, "name": p.name,
"description": p.description, "description": p.description,
"category_id": p.category_id, "category_id": p.category_id,
"main_unit_id": p.main_unit_id, "main_unit": p.main_unit,
"secondary_unit_id": p.secondary_unit_id, "secondary_unit": p.secondary_unit,
"unit_conversion_factor": p.unit_conversion_factor, "unit_conversion_factor": p.unit_conversion_factor,
"base_sales_price": p.base_sales_price, "base_sales_price": p.base_sales_price,
"base_sales_note": p.base_sales_note, "base_sales_note": p.base_sales_note,
@ -125,8 +125,13 @@ class ProductRepository(BaseRepository[Product]):
obj = self.db.get(Product, product_id) obj = self.db.get(Product, product_id)
if not obj: if not obj:
return None return None
# اجازه بده فیلدهای خاص حتی اگر None باشند هم ست شوند
nullable_overrides = {"main_unit_id", "secondary_unit_id", "unit_conversion_factor"}
for k, v in data.items(): for k, v in data.items():
if hasattr(obj, k) and v is not None: if hasattr(obj, k):
if k in nullable_overrides:
setattr(obj, k, v)
elif v is not None:
setattr(obj, k, v) setattr(obj, k, v)
self.db.commit() self.db.commit()
self.db.refresh(obj) self.db.refresh(obj)

View file

@ -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.cash_registers import router as cash_registers_router
from adapters.api.v1.petty_cash import router as petty_cash_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 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.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.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_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(cash_registers_router, prefix=settings.api_v1_prefix)
application.include_router(petty_cash_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(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) application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints

View file

@ -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: if not pr or pr.business_id != business_id:
raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404) raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404)
# اگر unit_id داده شده و با واحدهای محصول سازگار نباشد، خطا بده # اگر 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) raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400)
repo = PriceItemRepository(db) repo = PriceItemRepository(db)

View file

@ -1,6 +1,6 @@
from __future__ import annotations 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.orm import Session
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from decimal import Decimal from decimal import Decimal
@ -39,9 +39,20 @@ def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> None:
pass pass
def _validate_units(main_unit_id: Optional[int], secondary_unit_id: Optional[int], factor: Optional[Decimal]) -> None: def _validate_units(main_unit: Optional[str], secondary_unit: Optional[str], factor: Optional[Decimal]) -> None:
if secondary_unit_id and not factor: if secondary_unit and not factor:
raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400) 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: 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]: def create_product(db: Session, business_id: int, payload: ProductCreateRequest) -> Dict[str, Any]:
repo = ProductRepository(db) repo = ProductRepository(db)
_validate_tax(payload) _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 code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None
if code: if code:
@ -81,8 +95,8 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
name=payload.name.strip(), name=payload.name.strip(),
description=payload.description, description=payload.description,
category_id=payload.category_id, category_id=payload.category_id,
main_unit_id=payload.main_unit_id, main_unit=main_unit,
secondary_unit_id=payload.secondary_unit_id, secondary_unit=secondary_unit,
unit_conversion_factor=payload.unit_conversion_factor, unit_conversion_factor=payload.unit_conversion_factor,
base_sales_price=payload.base_sales_price, base_sales_price=payload.base_sales_price,
base_sales_note=payload.base_sales_note, 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) _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]: 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) raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
_validate_tax(payload) _validate_tax(payload)
_validate_units(payload.main_unit_id if payload.main_unit_id is not None else obj.main_unit_id, # از فیلدهای explicitly-set برای تشخیص پاک‌سازی (None) استفاده کن
payload.secondary_unit_id if payload.secondary_unit_id is not None else obj.secondary_unit_id, fields_set = getattr(payload, 'model_fields_set', getattr(payload, '__fields_set__', set()))
payload.unit_conversion_factor if payload.unit_conversion_factor is not None else obj.unit_conversion_factor) # 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( updated = repo.update(
product_id, 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, name=payload.name.strip() if isinstance(payload.name, str) else None,
description=payload.description, description=payload.description,
category_id=payload.category_id, category_id=payload.category_id,
main_unit_id=payload.main_unit_id, main_unit=main_unit_val if 'main_unit' in fields_set else None,
secondary_unit_id=payload.secondary_unit_id, secondary_unit=secondary_unit_val if 'secondary_unit' in fields_set else None,
unit_conversion_factor=payload.unit_conversion_factor, unit_conversion_factor=payload.unit_conversion_factor,
base_sales_price=payload.base_sales_price, base_sales_price=payload.base_sales_price,
base_sales_note=payload.base_sales_note, 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 return None
_upsert_attributes(db, product_id, business_id, payload.attribute_ids) _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: 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, "name": obj.name,
"description": obj.description, "description": obj.description,
"category_id": obj.category_id, "category_id": obj.category_id,
"main_unit_id": obj.main_unit_id, "main_unit": obj.main_unit,
"secondary_unit_id": obj.secondary_unit_id, "secondary_unit": obj.secondary_unit,
"unit_conversion_factor": obj.unit_conversion_factor, "unit_conversion_factor": obj.unit_conversion_factor,
"base_sales_price": obj.base_sales_price, "base_sales_price": obj.base_sales_price,
"base_sales_note": obj.base_sales_note, "base_sales_note": obj.base_sales_note,

View file

@ -65,6 +65,7 @@ adapters/db/models/price_list.py
adapters/db/models/product.py adapters/db/models/product.py
adapters/db/models/product_attribute.py adapters/db/models/product_attribute.py
adapters/db/models/product_attribute_link.py adapters/db/models/product_attribute_link.py
adapters/db/models/tax_type.py
adapters/db/models/tax_unit.py adapters/db/models/tax_unit.py
adapters/db/models/user.py adapters/db/models/user.py
adapters/db/models/support/__init__.py adapters/db/models/support/__init__.py
@ -138,6 +139,10 @@ hesabix_api.egg-info/top_level.txt
migrations/env.py migrations/env.py
migrations/versions/1f0abcdd7300_add_petty_cash_table.py migrations/versions/1f0abcdd7300_add_petty_cash_table.py
migrations/versions/20250102_000001_seed_support_data.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_000003_add_business_table.py
migrations/versions/20250117_000004_add_business_contact_fields.py migrations/versions/20250117_000004_add_business_contact_fields.py
migrations/versions/20250117_000005_add_business_geographic_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/20251002_000101_add_bank_accounts_table.py
migrations/versions/20251003_000201_add_cash_registers_table.py migrations/versions/20251003_000201_add_cash_registers_table.py
migrations/versions/20251003_010501_add_name_to_cash_registers.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/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.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/9f9786ae7191_create_tax_units_table.py
migrations/versions/a1443c153b47_merge_heads.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/c302bc2f2cb8_remove_person_type_column.py
migrations/versions/caf3f4ef4b76_add_tax_units_table.py migrations/versions/caf3f4ef4b76_add_tax_units_table.py
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py

View file

@ -17,7 +17,16 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # 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', op.create_table('petty_cash',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('business_id', sa.Integer(), nullable=False), sa.Column('business_id', sa.Integer(), nullable=False),

View file

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

View file

@ -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='وضعیت فعال/غیرفعال'))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,17 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# حذف ستون person_type از جدول persons # 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') op.drop_column('persons', 'person_type')

View file

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

View file

@ -3,7 +3,6 @@ import '../models/product_form_data.dart';
import '../services/product_service.dart'; import '../services/product_service.dart';
import '../services/category_service.dart'; import '../services/category_service.dart';
import '../services/product_attribute_service.dart'; import '../services/product_attribute_service.dart';
import '../services/unit_service.dart';
import '../services/tax_service.dart'; import '../services/tax_service.dart';
import '../services/price_list_service.dart'; import '../services/price_list_service.dart';
import '../services/currency_service.dart'; import '../services/currency_service.dart';
@ -16,7 +15,6 @@ class ProductFormController extends ChangeNotifier {
late final ProductService _productService; late final ProductService _productService;
late final CategoryService _categoryService; late final CategoryService _categoryService;
late final ProductAttributeService _attributeService; late final ProductAttributeService _attributeService;
late final UnitService _unitService;
late final TaxService _taxService; late final TaxService _taxService;
late final PriceListService _priceListService; late final PriceListService _priceListService;
late final CurrencyService _currencyService; late final CurrencyService _currencyService;
@ -29,7 +27,6 @@ class ProductFormController extends ChangeNotifier {
// Reference data // Reference data
List<Map<String, dynamic>> _categories = []; List<Map<String, dynamic>> _categories = [];
List<Map<String, dynamic>> _attributes = []; List<Map<String, dynamic>> _attributes = [];
List<Map<String, dynamic>> _units = [];
List<Map<String, dynamic>> _taxTypes = []; List<Map<String, dynamic>> _taxTypes = [];
List<Map<String, dynamic>> _taxUnits = []; List<Map<String, dynamic>> _taxUnits = [];
List<Map<String, dynamic>> _priceLists = []; List<Map<String, dynamic>> _priceLists = [];
@ -49,7 +46,6 @@ class ProductFormController extends ChangeNotifier {
_productService = ProductService(apiClient: _apiClient); _productService = ProductService(apiClient: _apiClient);
_categoryService = CategoryService(_apiClient); _categoryService = CategoryService(_apiClient);
_attributeService = ProductAttributeService(apiClient: _apiClient); _attributeService = ProductAttributeService(apiClient: _apiClient);
_unitService = UnitService(apiClient: _apiClient);
_taxService = TaxService(apiClient: _apiClient); _taxService = TaxService(apiClient: _apiClient);
_priceListService = PriceListService(apiClient: _apiClient); _priceListService = PriceListService(apiClient: _apiClient);
_currencyService = CurrencyService(_apiClient); _currencyService = CurrencyService(_apiClient);
@ -61,7 +57,6 @@ class ProductFormController extends ChangeNotifier {
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
List<Map<String, dynamic>> get categories => _categories; List<Map<String, dynamic>> get categories => _categories;
List<Map<String, dynamic>> get attributes => _attributes; List<Map<String, dynamic>> get attributes => _attributes;
List<Map<String, dynamic>> get units => _units;
List<Map<String, dynamic>> get taxTypes => _taxTypes; List<Map<String, dynamic>> get taxTypes => _taxTypes;
List<Map<String, dynamic>> get taxUnits => _taxUnits; List<Map<String, dynamic>> get taxUnits => _taxUnits;
List<Map<String, dynamic>> get priceLists => _priceLists; List<Map<String, dynamic>> 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(); _clearError();
notifyListeners(); notifyListeners();
@ -178,23 +161,17 @@ class ProductFormController extends ChangeNotifier {
_attributes = []; _attributes = [];
} }
// Load units
try {
_units = await _unitService.getUnits(businessId: businessId);
} catch (_) {
_units = [];
}
// Load tax types // Load tax types
try { try {
_taxTypes = await _taxService.getTaxTypes(businessId: businessId); _taxTypes = await _taxService.getTaxTypes();
} catch (_) { } catch (_) {
_taxTypes = []; _taxTypes = [];
} }
// Load tax units // Load tax units
try { try {
_taxUnits = await _taxService.getTaxUnits(businessId: businessId); _taxUnits = await _taxService.getTaxUnits();
} catch (_) { } catch (_) {
_taxUnits = []; _taxUnits = [];
} }

View file

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

View file

@ -19,8 +19,8 @@ class ProductFormData {
String? basePurchaseNote; String? basePurchaseNote;
// Units // Units
int? mainUnitId; String? mainUnit;
int? secondaryUnitId; String? secondaryUnit;
num unitConversionFactor; num unitConversionFactor;
// Taxes // Taxes
@ -49,8 +49,8 @@ class ProductFormData {
this.basePurchasePrice, this.basePurchasePrice,
this.baseSalesNote, this.baseSalesNote,
this.basePurchaseNote, this.basePurchaseNote,
this.mainUnitId, this.mainUnit = 'عدد',
this.secondaryUnitId, this.secondaryUnit,
this.unitConversionFactor = 1, this.unitConversionFactor = 1,
this.isSalesTaxable = false, this.isSalesTaxable = false,
this.isPurchaseTaxable = false, this.isPurchaseTaxable = false,
@ -76,8 +76,8 @@ class ProductFormData {
num? basePurchasePrice, num? basePurchasePrice,
String? baseSalesNote, String? baseSalesNote,
String? basePurchaseNote, String? basePurchaseNote,
int? mainUnitId, String? mainUnit,
int? secondaryUnitId, String? secondaryUnit,
num? unitConversionFactor, num? unitConversionFactor,
bool? isSalesTaxable, bool? isSalesTaxable,
bool? isPurchaseTaxable, bool? isPurchaseTaxable,
@ -102,8 +102,8 @@ class ProductFormData {
basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice, basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice,
baseSalesNote: baseSalesNote ?? this.baseSalesNote, baseSalesNote: baseSalesNote ?? this.baseSalesNote,
basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote, basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote,
mainUnitId: mainUnitId ?? this.mainUnitId, mainUnit: mainUnit ?? this.mainUnit,
secondaryUnitId: secondaryUnitId ?? this.secondaryUnitId, secondaryUnit: secondaryUnit ?? this.secondaryUnit,
unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor, unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor,
isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable, isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable,
isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable, isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable,
@ -134,9 +134,9 @@ class ProductFormData {
'is_purchase_taxable': isPurchaseTaxable, 'is_purchase_taxable': isPurchaseTaxable,
'sales_tax_rate': salesTaxRate ?? 0, 'sales_tax_rate': salesTaxRate ?? 0,
'purchase_tax_rate': purchaseTaxRate ?? 0, 'purchase_tax_rate': purchaseTaxRate ?? 0,
// Keep optional IDs and factor as-is (do not force zero) // Units as strings
'main_unit_id': mainUnitId, 'main_unit': mainUnit,
'secondary_unit_id': secondaryUnitId, 'secondary_unit': secondaryUnit,
'unit_conversion_factor': unitConversionFactor, 'unit_conversion_factor': unitConversionFactor,
'base_sales_note': baseSalesNote, 'base_sales_note': baseSalesNote,
'base_purchase_note': basePurchaseNote, 'base_purchase_note': basePurchaseNote,
@ -160,8 +160,8 @@ class ProductFormData {
trackInventory: (product['track_inventory'] == true), trackInventory: (product['track_inventory'] == true),
baseSalesPrice: _parseNumeric(product['base_sales_price']), baseSalesPrice: _parseNumeric(product['base_sales_price']),
basePurchasePrice: _parseNumeric(product['base_purchase_price']), basePurchasePrice: _parseNumeric(product['base_purchase_price']),
mainUnitId: product['main_unit_id'] as int?, mainUnit: product['main_unit']?.toString(),
secondaryUnitId: product['secondary_unit_id'] as int?, secondaryUnit: product['secondary_unit']?.toString(),
unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1, unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1,
baseSalesNote: product['base_sales_note']?.toString(), baseSalesNote: product['base_sales_note']?.toString(),
basePurchaseNote: product['base_purchase_note']?.toString(), basePurchaseNote: product['base_purchase_note']?.toString(),

View file

@ -16,6 +16,8 @@ import '../../core/date_utils.dart';
import '../../models/invoice_type_model.dart'; import '../../models/invoice_type_model.dart';
import '../../models/customer_model.dart'; import '../../models/customer_model.dart';
import '../../models/person_model.dart'; import '../../models/person_model.dart';
import '../../widgets/invoice/line_items_table.dart';
import '../../utils/number_formatters.dart';
class NewInvoicePage extends StatefulWidget { class NewInvoicePage extends StatefulWidget {
final int businessId; final int businessId;
@ -50,11 +52,37 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
int? _selectedCurrencyId; int? _selectedCurrencyId;
String? _invoiceTitle; String? _invoiceTitle;
String? _invoiceReference; String? _invoiceReference;
// جمعهای محاسباتی ردیفها
num _sumSubtotal = 0;
num _sumDiscount = 0;
num _sumTax = 0;
num _sumTotal = 0;
@override @override
void initState() { void initState() {
super.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 @override
@ -77,20 +105,21 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
toolbarHeight: 56, toolbarHeight: 56,
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: [
Tab( const Tab(
icon: Icon(Icons.info_outline), icon: Icon(Icons.info_outline),
text: 'اطلاعات فاکتور', text: 'اطلاعات فاکتور',
), ),
Tab( const Tab(
icon: Icon(Icons.inventory_2_outlined), icon: Icon(Icons.inventory_2_outlined),
text: 'کالاها و خدمات', text: 'کالاها و خدمات',
), ),
Tab( if (_shouldShowTransactionsTab)
const Tab(
icon: Icon(Icons.receipt_long_outlined), icon: Icon(Icons.receipt_long_outlined),
text: 'تراکنش‌ها', text: 'تراکنش‌ها',
), ),
Tab( const Tab(
icon: Icon(Icons.settings_outlined), icon: Icon(Icons.settings_outlined),
text: 'تنظیمات', text: 'تنظیمات',
), ),
@ -104,8 +133,8 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
_buildInvoiceInfoTab(), _buildInvoiceInfoTab(),
// تب کالاها و خدمات // تب کالاها و خدمات
_buildProductsTab(), _buildProductsTab(),
// تب تراکنشها // تب تراکنشها (فقط اگر باید نمایش داده شود)
_buildTransactionsTab(), if (_shouldShowTransactionsTab) _buildTransactionsTab(),
// تب تنظیمات // تب تنظیمات
_buildSettingsTab(), _buildSettingsTab(),
], ],
@ -135,6 +164,12 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
onTypeChanged: (type) { onTypeChanged: (type) {
setState(() { setState(() {
_selectedInvoiceType = type; _selectedInvoiceType = type;
// بهروزرسانی TabController اگر تعداد تبها تغییر کرده
final newTabCount = _getTabCountForType(type);
if (newTabCount != _tabController.length) {
_tabController.dispose();
_tabController = TabController(length: newTabCount, vsync: this);
}
}); });
}, },
isDraft: _isDraft, isDraft: _isDraft,
@ -364,6 +399,12 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
onTypeChanged: (type) { onTypeChanged: (type) {
setState(() { setState(() {
_selectedInvoiceType = type; _selectedInvoiceType = type;
// بهروزرسانی TabController اگر تعداد تبها تغییر کرده
final newTabCount = _getTabCountForType(type);
if (newTabCount != _tabController.length) {
_tabController.dispose();
_tabController = TabController(length: newTabCount, vsync: this);
}
}); });
}, },
isDraft: _isDraft, isDraft: _isDraft,
@ -806,34 +847,46 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
} }
Widget _buildProductsTab() { Widget _buildProductsTab() {
return const Center( return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1600),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon( InvoiceLineItemsTable(
Icons.inventory_2_outlined, businessId: widget.businessId,
size: 64, selectedCurrencyId: _selectedCurrencyId,
color: Colors.grey, invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
onChanged: (rows) {
setState(() {
_sumSubtotal = rows.fold<num>(0, (acc, e) => acc + e.subtotal);
_sumDiscount = rows.fold<num>(0, (acc, e) => acc + e.discountAmount);
_sumTax = rows.fold<num>(0, (acc, e) => acc + e.taxAmount);
_sumTotal = rows.fold<num>(0, (acc, e) => acc + e.total);
});
},
), ),
SizedBox(height: 16), const SizedBox(height: 12),
Text( // نوار خلاصه جمعها در والد (برای همگامسازی با سایر بخشها)
'کالاها و خدمات', Align(
style: TextStyle( alignment: Alignment.centerLeft,
fontSize: 24, child: Wrap(
fontWeight: FontWeight.bold, spacing: 16,
color: Colors.grey, runSpacing: 8,
), children: [
), Text('جمع مبلغ: ${formatWithThousands(_sumSubtotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
SizedBox(height: 8), Text('جمع تخفیف: ${formatWithThousands(_sumDiscount, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
Text( Text('جمع مالیات: ${formatWithThousands(_sumTax, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
'این بخش در آینده پیاده‌سازی خواهد شد', Text('جمع کل: ${formatWithThousands(_sumTotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
style: TextStyle( ],
fontSize: 16,
color: Colors.grey,
), ),
), ),
], ],
), ),
),
),
); );
} }

View file

@ -78,6 +78,33 @@ class ProductService {
} }
return false; return false;
} }
Future<List<Map<String, dynamic>>> searchProducts({
required int businessId,
String? searchQuery,
int limit = 20,
int skip = 0,
List<Map<String, dynamic>>? filters,
List<String>? searchFields,
}) async {
final body = <String, dynamic>{
'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<Map<String, dynamic>>(
'/api/v1/products/business/$businessId/search',
data: body,
);
final data = res.data?['data'];
final items = (data is Map<String, dynamic>) ? data['items'] : null;
if (items is List) {
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map)).toList();
}
return const <Map<String, dynamic>>[];
}
} }

View file

@ -5,10 +5,10 @@ class TaxService {
final ApiClient _apiClient; final ApiClient _apiClient;
TaxService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); TaxService({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient();
Future<List<Map<String, dynamic>>> getTaxTypes({required int businessId}) async { Future<List<Map<String, dynamic>>> getTaxTypes({int? businessId}) async {
try { try {
final res = await _apiClient.get<Map<String, dynamic>>( final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/tax-types/business/$businessId', '/api/v1/tax-types/',
); );
final data = res.data?['data']; final data = res.data?['data'];
if (data is List) { if (data is List) {
@ -28,10 +28,10 @@ class TaxService {
} }
} }
Future<List<Map<String, dynamic>>> getTaxUnits({required int businessId}) async { Future<List<Map<String, dynamic>>> getTaxUnits({int? businessId}) async {
try { try {
final res = await _apiClient.get<Map<String, dynamic>>( final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/tax-units/business/$businessId', '/api/v1/tax-units/',
); );
final data = res.data?['data']; final data = res.data?['data'];
if (data is List) { if (data is List) {

File diff suppressed because it is too large Load diff

View file

@ -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<Map<String, dynamic>?> 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<PriceListComboboxWidget> createState() => _PriceListComboboxWidgetState();
}
class _PriceListComboboxWidgetState extends State<PriceListComboboxWidget> {
final PriceListService _service = PriceListService(apiClient: ApiClient());
bool _loading = false;
List<Map<String, dynamic>> _items = const <Map<String, dynamic>>[];
Map<String, dynamic>? _selected;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await _service.listPriceLists(businessId: widget.businessId, limit: 50);
final items = (res['items'] as List?)?.cast<dynamic>().map((e) => Map<String, dynamic>.from(e as Map)).toList() ?? const <Map<String, dynamic>>[];
Map<String, dynamic>? selected;
if (widget.selectedPriceListId != null) {
selected = items.firstWhere((e) => e['id'] == widget.selectedPriceListId, orElse: () => <String, dynamic>{});
if (selected.isEmpty) selected = null;
}
setState(() {
_items = items;
_selected = selected ?? (items.isNotEmpty ? items.first : null);
});
widget.onChanged(_selected);
} catch (_) {
setState(() {
_items = const <Map<String, dynamic>>[];
_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<int>(
value: _selected != null ? (_selected!['id'] as int) : null,
isExpanded: true,
items: _items
.map((e) => DropdownMenuItem<int>(
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: () => <String, dynamic>{});
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),
),
),
),
);
}
}

View file

@ -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<String, dynamic>? selectedProduct;
final ValueChanged<Map<String, dynamic>?> 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<ProductComboboxWidget> createState() => _ProductComboboxWidgetState();
}
class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
final ProductService _service = ProductService(apiClient: ApiClient());
final TextEditingController _searchCtrl = TextEditingController();
Timer? _debounce;
bool _loading = false;
List<Map<String, dynamic>> _items = const <Map<String, dynamic>>[];
@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<void> _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 <Map<String, dynamic>>[]);
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _onQueryChanged(String q) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () => _performSearch(q.trim()));
}
Future<void> _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 <Map<String, dynamic>>[]);
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _select(Map<String, dynamic>? 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),
],
),
),
),
);
}
}

View file

@ -131,7 +131,6 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
onChanged: _controller.updateFormData, onChanged: _controller.updateFormData,
categories: _controller.categories, categories: _controller.categories,
attributes: _controller.attributes, attributes: _controller.attributes,
units: _controller.units,
), ),
); );
} }
@ -142,7 +141,6 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
child: ProductPricingInventorySection( child: ProductPricingInventorySection(
formData: _controller.formData, formData: _controller.formData,
onChanged: _controller.updateFormData, onChanged: _controller.updateFormData,
units: _controller.units,
priceLists: _controller.priceLists, priceLists: _controller.priceLists,
currencies: _controller.currencies, currencies: _controller.currencies,
draftPriceItems: _controller.draftPriceItems, draftPriceItems: _controller.draftPriceItems,

View file

@ -11,7 +11,6 @@ class ProductBasicInfoSection extends StatelessWidget {
final ValueChanged<ProductFormData> onChanged; final ValueChanged<ProductFormData> onChanged;
final List<Map<String, dynamic>> categories; final List<Map<String, dynamic>> categories;
final List<Map<String, dynamic>> attributes; final List<Map<String, dynamic>> attributes;
final List<Map<String, dynamic>> units;
const ProductBasicInfoSection({ const ProductBasicInfoSection({
super.key, super.key,
@ -20,7 +19,6 @@ class ProductBasicInfoSection extends StatelessWidget {
required this.onChanged, required this.onChanged,
required this.categories, required this.categories,
required this.attributes, required this.attributes,
required this.units,
}); });
@override @override
@ -161,44 +159,55 @@ class ProductBasicInfoSection extends StatelessWidget {
} }
Widget _buildUnitsSection(BuildContext context) { Widget _buildUnitsSection(BuildContext context) {
final t = AppLocalizations.of(context);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded(child: _buildUnitTextField( Expanded(
context: context, child: TextFormField(
label: AppLocalizations.of(context).mainUnit, initialValue: formData.mainUnit ?? '',
isRequired: true, decoration: InputDecoration(labelText: t.mainUnit),
initialText: _unitNameById(formData.mainUnitId) ?? 'عدد', validator: (v) => (v == null || v.trim().isEmpty) ? t.required : null,
onChanged: (text) { onChanged: (text) {
final mappedId = _findUnitIdByTitle(text); _updateFormData(formData.copyWith(
_updateFormData(formData.copyWith(mainUnitId: mappedId)); mainUnit: text.trim().isEmpty ? null : text.trim(),
));
}, },
)), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: _buildUnitTextField( Expanded(
context: context, child: TextFormField(
label: AppLocalizations.of(context).secondaryUnit, initialValue: formData.secondaryUnit ?? '',
isRequired: false, decoration: InputDecoration(labelText: t.secondaryUnit),
initialText: _unitNameById(formData.secondaryUnitId) ?? '',
onChanged: (text) { onChanged: (text) {
final mappedId = _findUnitIdByTitle(text); _updateFormData(formData.copyWith(
_updateFormData(formData.copyWith(secondaryUnitId: mappedId)); secondaryUnit: text.trim().isEmpty ? null : text.trim(),
));
}, },
)), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: TextFormField( child: TextFormField(
initialValue: formData.unitConversionFactor.toString(), initialValue: formData.unitConversionFactor.toString(),
decoration: InputDecoration(labelText: AppLocalizations.of(context).unitConversionFactor), decoration: InputDecoration(labelText: t.unitConversionFactor),
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [ 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))), 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<String> 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) { Widget _buildItemTypeSelector(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);

View file

@ -8,7 +8,6 @@ import '../../../utils/product_form_validator.dart';
class ProductPricingInventorySection extends StatelessWidget { class ProductPricingInventorySection extends StatelessWidget {
final ProductFormData formData; final ProductFormData formData;
final ValueChanged<ProductFormData> onChanged; final ValueChanged<ProductFormData> onChanged;
final List<Map<String, dynamic>> units;
final List<Map<String, dynamic>> priceLists; final List<Map<String, dynamic>> priceLists;
final List<Map<String, dynamic>> currencies; final List<Map<String, dynamic>> currencies;
final List<Map<String, dynamic>> draftPriceItems; final List<Map<String, dynamic>> draftPriceItems;
@ -19,7 +18,6 @@ class ProductPricingInventorySection extends StatelessWidget {
super.key, super.key,
required this.formData, required this.formData,
required this.onChanged, required this.onChanged,
required this.units,
required this.priceLists, required this.priceLists,
required this.currencies, required this.currencies,
required this.draftPriceItems, required this.draftPriceItems,

View file

@ -4,7 +4,7 @@ import 'package:flutter/services.dart';
import '../../../models/product_form_data.dart'; import '../../../models/product_form_data.dart';
import '../../../utils/product_form_validator.dart'; import '../../../utils/product_form_validator.dart';
class ProductTaxSection extends StatelessWidget { class ProductTaxSection extends StatefulWidget {
final ProductFormData formData; final ProductFormData formData;
final ValueChanged<ProductFormData> onChanged; final ValueChanged<ProductFormData> onChanged;
final List<Map<String, dynamic>> taxTypes; final List<Map<String, dynamic>> taxTypes;
@ -18,6 +18,12 @@ class ProductTaxSection extends StatelessWidget {
required this.taxUnits, required this.taxUnits,
}); });
@override
State<ProductTaxSection> createState() => _ProductTaxSectionState();
}
class _ProductTaxSectionState extends State<ProductTaxSection> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
@ -66,10 +72,10 @@ class ProductTaxSection extends StatelessWidget {
Widget _buildTaxCodeField(BuildContext context) { Widget _buildTaxCodeField(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return TextFormField( return TextFormField(
initialValue: formData.taxCode, initialValue: widget.formData.taxCode,
decoration: InputDecoration(labelText: t.taxCode), decoration: InputDecoration(labelText: t.taxCode),
onChanged: (value) => _updateFormData( onChanged: (value) => widget.onChanged(
formData.copyWith(taxCode: value.trim().isEmpty ? null : value), widget.formData.copyWith(taxCode: value.trim().isEmpty ? null : value),
), ),
); );
} }
@ -80,21 +86,21 @@ class ProductTaxSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SwitchListTile( SwitchListTile(
value: formData.isSalesTaxable, value: widget.formData.isSalesTaxable,
onChanged: (value) => _updateFormData(formData.copyWith(isSalesTaxable: value)), onChanged: (value) => widget.onChanged(widget.formData.copyWith(isSalesTaxable: value)),
title: Text(t.isSalesTaxable), title: Text(t.isSalesTaxable),
), ),
if (formData.isSalesTaxable) ...[ if (widget.formData.isSalesTaxable) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
initialValue: formData.salesTaxRate?.toString(), initialValue: widget.formData.salesTaxRate?.toString(),
decoration: InputDecoration(labelText: t.salesTaxRate), decoration: InputDecoration(labelText: t.salesTaxRate),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
], ],
validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: t.salesTaxRate), 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SwitchListTile( SwitchListTile(
value: formData.isPurchaseTaxable, value: widget.formData.isPurchaseTaxable,
onChanged: (value) => _updateFormData(formData.copyWith(isPurchaseTaxable: value)), onChanged: (value) => widget.onChanged(widget.formData.copyWith(isPurchaseTaxable: value)),
title: Text(t.isPurchaseTaxable), title: Text(t.isPurchaseTaxable),
), ),
if (formData.isPurchaseTaxable) ...[ if (widget.formData.isPurchaseTaxable) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
initialValue: formData.purchaseTaxRate?.toString(), initialValue: widget.formData.purchaseTaxRate?.toString(),
decoration: InputDecoration(labelText: t.purchaseTaxRate), decoration: InputDecoration(labelText: t.purchaseTaxRate),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
], ],
validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: t.purchaseTaxRate), 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) { Widget _buildTaxTypeDropdown(BuildContext context) {
if (taxTypes.isNotEmpty) { if (widget.taxTypes.isNotEmpty) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return DropdownButtonFormField<int>( return DropdownButtonFormField<int>(
value: formData.taxTypeId, initialValue: widget.formData.taxTypeId,
items: taxTypes items: [
DropdownMenuItem<int>(
value: null,
child: Text('انتخاب ${t.taxType}'),
),
...widget.taxTypes
.map((taxType) => DropdownMenuItem<int>( .map((taxType) => DropdownMenuItem<int>(
value: (taxType['id'] as num).toInt(), value: (taxType['id'] as num).toInt(),
child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()), child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()),
)) )),
.toList(), ],
onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: value)), onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxTypeId: value)),
decoration: InputDecoration(labelText: t.taxType), decoration: InputDecoration(labelText: t.taxType),
); );
} else { } else {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return TextFormField( return TextFormField(
initialValue: formData.taxTypeId?.toString(), initialValue: widget.formData.taxTypeId?.toString(),
decoration: InputDecoration(labelText: t.taxTypeId), decoration: InputDecoration(labelText: t.taxType),
keyboardType: TextInputType.number, 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) { Widget _buildTaxUnitDropdown(BuildContext context) {
final List<Map<String, dynamic>> effectiveTaxUnits = taxUnits.isNotEmpty ? taxUnits : _fallbackTaxUnits(); final List<Map<String, dynamic>> effectiveTaxUnits = widget.taxUnits.isNotEmpty ? widget.taxUnits : _fallbackTaxUnits();
if (effectiveTaxUnits.isNotEmpty) { if (effectiveTaxUnits.isNotEmpty) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return DropdownButtonFormField<int>( return DropdownButtonFormField<int>(
value: formData.taxUnitId, initialValue: widget.formData.taxUnitId,
items: effectiveTaxUnits items: [
DropdownMenuItem<int>(
value: null,
child: Text('انتخاب ${t.taxUnit}'),
),
...effectiveTaxUnits
.map((taxUnit) => DropdownMenuItem<int>( .map((taxUnit) => DropdownMenuItem<int>(
value: (taxUnit['id'] as num).toInt(), value: (taxUnit['id'] as num).toInt(),
child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()), child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()),
)) )),
.toList(), ],
onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: value)), onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxUnitId: value)),
decoration: InputDecoration(labelText: t.taxUnit), decoration: InputDecoration(labelText: t.taxUnit),
); );
} else { } else {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return TextFormField( return TextFormField(
initialValue: formData.taxUnitId?.toString(), initialValue: widget.formData.taxUnitId?.toString(),
decoration: InputDecoration(labelText: t.taxUnitId), decoration: InputDecoration(labelText: t.taxUnitId),
keyboardType: TextInputType.number, 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);
}
} }