progress in invoice and products
This commit is contained in:
parent
0edff7d020
commit
7f6a78f642
|
|
@ -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", "ایجاد"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
summary="لیست نوعهای مالیات",
|
||||||
"دارو",
|
description="دریافت لیست تمام نوعهای مالیات استاندارد",
|
||||||
"دخانیات",
|
|
||||||
"موبایل",
|
|
||||||
"لوازم خانگی برقی",
|
|
||||||
"قطعات مصرفی و یدکی وسایل نقلیه",
|
|
||||||
"فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
|
|
||||||
"طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
|
|
||||||
"منسوجات و پوشاک",
|
|
||||||
"اسباب بازی",
|
|
||||||
"دام زنده، گوشت سفید و قرمز",
|
|
||||||
"محصولات اساسی کشاورزی",
|
|
||||||
"سایر کالا ها",
|
|
||||||
]
|
|
||||||
return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/business/{business_id}",
|
|
||||||
summary="لیست نوعهای مالیات",
|
|
||||||
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()
|
|
||||||
return success_response(items, request)
|
items = [
|
||||||
|
{
|
||||||
|
"id": it.id,
|
||||||
|
"title": it.title,
|
||||||
|
"code": it.code,
|
||||||
|
"description": it.description,
|
||||||
|
"created_at": it.created_at.isoformat(),
|
||||||
|
"updated_at": it.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for it in db.query(TaxType).order_by(TaxType.title).all()
|
||||||
|
]
|
||||||
|
return success_response(items, request)
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
# قیمتهای پایه (نمایشی)
|
# قیمتهای پایه (نمایشی)
|
||||||
|
|
|
||||||
24
hesabixAPI/adapters/db/models/tax_type.py
Normal file
24
hesabixAPI/adapters/db/models/tax_type.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,9 +125,14 @@ 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):
|
||||||
setattr(obj, k, v)
|
if k in nullable_overrides:
|
||||||
|
setattr(obj, k, v)
|
||||||
|
elif v is not None:
|
||||||
|
setattr(obj, k, v)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(obj)
|
self.db.refresh(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,17 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# Check if table already exists
|
||||||
op.create_table('petty_cash',
|
connection = op.get_bind()
|
||||||
|
result = connection.execute(sa.text("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'petty_cash'
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
if result[0] == 0:
|
||||||
|
op.create_table('petty_cash',
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('name', sa.String(length=255), nullable=False, comment='نام تنخواه گردان'),
|
sa.Column('name', sa.String(length=255), nullable=False, comment='نام تنخواه گردان'),
|
||||||
|
|
@ -33,11 +42,11 @@ def upgrade() -> None:
|
||||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
|
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code')
|
sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code')
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False)
|
op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False)
|
||||||
op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False)
|
op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False)
|
||||||
op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False)
|
op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False)
|
||||||
op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False)
|
op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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='وضعیت فعال/غیرفعال'))
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -17,8 +17,18 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# حذف ستون person_type از جدول persons
|
# Check if column exists before dropping
|
||||||
op.drop_column('persons', 'person_type')
|
connection = op.get_bind()
|
||||||
|
result = connection.execute(sa.text("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'persons'
|
||||||
|
AND column_name = 'person_type'
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
if result[0] > 0:
|
||||||
|
op.drop_column('persons', 'person_type')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
99
hesabixAPI/scripts/seed_tax_types.py
Normal file
99
hesabixAPI/scripts/seed_tax_types.py
Normal 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()
|
||||||
|
|
@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
120
hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
Normal file
120
hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
icon: Icon(Icons.receipt_long_outlined),
|
const Tab(
|
||||||
text: 'تراکنشها',
|
icon: Icon(Icons.receipt_long_outlined),
|
||||||
),
|
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,33 +847,45 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProductsTab() {
|
Widget _buildProductsTab() {
|
||||||
return const Center(
|
return Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(16),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Center(
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
Icon(
|
constraints: const BoxConstraints(maxWidth: 1600),
|
||||||
Icons.inventory_2_outlined,
|
child: Column(
|
||||||
size: 64,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
color: Colors.grey,
|
children: [
|
||||||
|
InvoiceLineItemsTable(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// نوار خلاصه جمعها در والد (برای همگامسازی با سایر بخشها)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Text('جمع مبلغ: ${formatWithThousands(_sumSubtotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
Text('جمع تخفیف: ${formatWithThousands(_sumDiscount, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
Text('جمع مالیات: ${formatWithThousands(_sumTax, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
Text('جمع کل: ${formatWithThousands(_sumTotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
),
|
||||||
Text(
|
|
||||||
'کالاها و خدمات',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'این بخش در آینده پیادهسازی خواهد شد',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>>[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
1058
hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart
Normal file
1058
hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
_updateFormData(formData.copyWith(
|
||||||
final mappedId = _findUnitIdByTitle(text);
|
secondaryUnit: text.trim().isEmpty ? null : text.trim(),
|
||||||
_updateFormData(formData.copyWith(secondaryUnitId: mappedId));
|
));
|
||||||
},
|
},
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
.map((taxType) => DropdownMenuItem<int>(
|
DropdownMenuItem<int>(
|
||||||
value: (taxType['id'] as num).toInt(),
|
value: null,
|
||||||
child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()),
|
child: Text('انتخاب ${t.taxType}'),
|
||||||
))
|
),
|
||||||
.toList(),
|
...widget.taxTypes
|
||||||
onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: value)),
|
.map((taxType) => DropdownMenuItem<int>(
|
||||||
|
value: (taxType['id'] as num).toInt(),
|
||||||
|
child: Text((taxType['title'] ?? taxType['name'] ?? 'نوع ${taxType['id']}').toString()),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxTypeId: value)),
|
||||||
decoration: InputDecoration(labelText: t.taxType),
|
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: [
|
||||||
.map((taxUnit) => DropdownMenuItem<int>(
|
DropdownMenuItem<int>(
|
||||||
value: (taxUnit['id'] as num).toInt(),
|
value: null,
|
||||||
child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()),
|
child: Text('انتخاب ${t.taxUnit}'),
|
||||||
))
|
),
|
||||||
.toList(),
|
...effectiveTaxUnits
|
||||||
onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: value)),
|
.map((taxUnit) => DropdownMenuItem<int>(
|
||||||
|
value: (taxUnit['id'] as num).toInt(),
|
||||||
|
child: Text((taxUnit['title'] ?? taxUnit['name'] ?? 'واحد ${taxUnit['id']}').toString()),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (value) => widget.onChanged(widget.formData.copyWith(taxUnitId: value)),
|
||||||
decoration: InputDecoration(labelText: t.taxUnit),
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue