feat(products): add product bundles to sales invoices #11
20 changed files with 1888 additions and 6 deletions
134
hesabixAPI/adapters/api/v1/product_bundles.py
Normal file
134
hesabixAPI/adapters/api/v1/product_bundles.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from adapters.api.v1.schema_models.product_bundle import (
|
||||
ProductBundleCreateRequest,
|
||||
ProductBundleExpandRequest,
|
||||
ProductBundleUpdateRequest,
|
||||
)
|
||||
from adapters.api.v1.schemas import QueryInfo
|
||||
from adapters.db.session import get_db
|
||||
from app.core.auth_dependency import AuthContext, get_current_user
|
||||
from app.core.permissions import require_business_access, require_business_permission_dep
|
||||
from app.core.responses import ApiError, format_datetime_fields, success_response
|
||||
from app.services.product_bundle_service import (
|
||||
create_bundle,
|
||||
delete_bundle,
|
||||
expand_bundle,
|
||||
get_bundle,
|
||||
search_bundles,
|
||||
update_bundle,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/product-bundles", tags=["product-bundles"])
|
||||
|
||||
|
||||
@router.post("/business/{business_id}/search")
|
||||
@require_business_access("business_id")
|
||||
def search_product_bundles(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
query: QueryInfo,
|
||||
ctx: Annotated[AuthContext, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[None, Depends(require_business_permission_dep("products", "view"))],
|
||||
) -> dict[str, Any]:
|
||||
result = search_bundles(
|
||||
db,
|
||||
business_id,
|
||||
{
|
||||
"take": query.take,
|
||||
"skip": query.skip,
|
||||
"sort_by": query.sort_by,
|
||||
"sort_desc": query.sort_desc,
|
||||
"search": query.search,
|
||||
"filters": [item.model_dump() for item in query.filters] if query.filters else None,
|
||||
},
|
||||
)
|
||||
return success_response(format_datetime_fields(result, request), request)
|
||||
|
||||
|
||||
@router.post("/business/{business_id}")
|
||||
@require_business_access("business_id")
|
||||
def create_product_bundle(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
payload: ProductBundleCreateRequest,
|
||||
ctx: Annotated[AuthContext, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[None, Depends(require_business_permission_dep("products", "add"))],
|
||||
) -> dict[str, Any]:
|
||||
result = create_bundle(db, business_id, payload)
|
||||
return success_response(
|
||||
format_datetime_fields(result, request), request, message="BUNDLE_CREATED"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/business/{business_id}/{bundle_id}")
|
||||
@require_business_access("business_id")
|
||||
def get_product_bundle(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
bundle_id: int,
|
||||
ctx: Annotated[AuthContext, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[None, Depends(require_business_permission_dep("products", "view"))],
|
||||
) -> dict[str, Any]:
|
||||
result = get_bundle(db, business_id, bundle_id)
|
||||
if result is None:
|
||||
raise ApiError("BUNDLE_NOT_FOUND", "باندل یافت نشد.", http_status=404)
|
||||
return success_response(format_datetime_fields(result, request), request)
|
||||
|
||||
|
||||
@router.put("/business/{business_id}/{bundle_id}")
|
||||
@require_business_access("business_id")
|
||||
def update_product_bundle(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
bundle_id: int,
|
||||
payload: ProductBundleUpdateRequest,
|
||||
ctx: Annotated[AuthContext, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[None, Depends(require_business_permission_dep("products", "edit"))],
|
||||
) -> dict[str, Any]:
|
||||
result = update_bundle(db, business_id, bundle_id, payload)
|
||||
if result is None:
|
||||
raise ApiError("BUNDLE_NOT_FOUND", "باندل یافت نشد.", http_status=404)
|
||||
return success_response(
|
||||
format_datetime_fields(result, request), request, message="BUNDLE_UPDATED"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/business/{business_id}/{bundle_id}")
|
||||
@require_business_access("business_id")
|
||||
def delete_product_bundle(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
bundle_id: int,
|
||||
ctx: Annotated[AuthContext, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[None, Depends(require_business_permission_dep("products", "delete"))],
|
||||
) -> dict[str, Any]:
|
||||
deleted = delete_bundle(db, business_id, bundle_id)
|
||||
if not deleted:
|
||||
raise ApiError("BUNDLE_NOT_FOUND", "باندل یافت نشد.", http_status=404)
|
||||
return success_response({"deleted": True, "id": bundle_id}, request, message="BUNDLE_DELETED")
|
||||
|
||||
|
||||
@router.post("/business/{business_id}/{bundle_id}/expand")
|
||||
@require_business_access("business_id")
|
||||
def expand_product_bundle(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
bundle_id: int,
|
||||
payload: ProductBundleExpandRequest,
|
||||
ctx: Annotated[AuthContext, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[None, Depends(require_business_permission_dep("products", "view"))],
|
||||
) -> dict[str, Any]:
|
||||
result = expand_bundle(db, business_id, bundle_id, payload.quantity)
|
||||
return success_response(format_datetime_fields(result, request), request)
|
||||
46
hesabixAPI/adapters/api/v1/schema_models/product_bundle.py
Normal file
46
hesabixAPI/adapters/api/v1/schema_models/product_bundle.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ProductBundleItemRequest(BaseModel):
|
||||
product_id: int = Field(..., gt=0)
|
||||
quantity: Decimal = Field(..., gt=0, max_digits=18, decimal_places=6)
|
||||
|
||||
|
||||
class ProductBundleCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=5000)
|
||||
is_active: bool = True
|
||||
items: list[ProductBundleItemRequest] = Field(..., min_length=1, max_length=500)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("نام باندل نمیتواند خالی باشد")
|
||||
return value
|
||||
|
||||
|
||||
class ProductBundleUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=5000)
|
||||
is_active: bool | None = None
|
||||
items: list[ProductBundleItemRequest] | None = Field(default=None, min_length=1, max_length=500)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("نام باندل نمیتواند خالی باشد")
|
||||
return value
|
||||
|
||||
|
||||
class ProductBundleExpandRequest(BaseModel):
|
||||
quantity: Decimal = Field(default=Decimal("1"), gt=0, max_digits=18, decimal_places=6)
|
||||
|
|
@ -60,6 +60,7 @@ from .category import BusinessCategory # noqa: F401
|
|||
from .product_attribute import ProductAttribute # noqa: F401
|
||||
from .business_catalog_spec_field import BusinessCatalogSpecField # noqa: F401
|
||||
from .product import Product # noqa: F401
|
||||
from .product_bundle import ProductBundle, ProductBundleItem # noqa: F401
|
||||
from .product_supplier import ProductSupplier, ProductSupplierSocialContact # noqa: F401
|
||||
from .public_catalog_contact_message import PublicCatalogContactMessage # noqa: F401
|
||||
from .product_general_barcode_alias import ProductGeneralBarcodeAlias # noqa: F401
|
||||
|
|
|
|||
87
hesabixAPI/adapters/db/models/product_bundle.py
Normal file
87
hesabixAPI/adapters/db/models/product_bundle.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class ProductBundle(Base):
|
||||
"""یک الگوی ورود سریع چند کالا به فاکتور؛ بدون قیمت و موجودی مستقل."""
|
||||
|
||||
__tablename__ = "product_bundles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("business_id", "name", name="uq_product_bundles_business_name"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
business_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true", index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
)
|
||||
|
||||
items: Mapped[list[ProductBundleItem]] = relationship(
|
||||
"ProductBundleItem",
|
||||
back_populates="bundle",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ProductBundleItem.line_order",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class ProductBundleItem(Base):
|
||||
"""کالا و تعداد آن در یک Bundle."""
|
||||
|
||||
__tablename__ = "product_bundle_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("bundle_id", "product_id", name="uq_product_bundle_items_product"),
|
||||
UniqueConstraint("bundle_id", "line_order", name="uq_product_bundle_items_line_order"),
|
||||
CheckConstraint("quantity > 0", name="ck_product_bundle_items_quantity_positive"),
|
||||
CheckConstraint("line_order > 0", name="ck_product_bundle_items_line_order_positive"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
bundle_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("product_bundles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("products.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
|
||||
line_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
bundle: Mapped[ProductBundle] = relationship("ProductBundle", back_populates="items")
|
||||
product = relationship("Product", lazy="joined")
|
||||
|
|
@ -1014,6 +1014,8 @@ def create_app() -> FastAPI:
|
|||
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(catalog_spec_fields_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.product_bundles import router as product_bundles_router
|
||||
application.include_router(product_bundles_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.product_instances import router as product_instances_router
|
||||
application.include_router(product_instances_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.warehouse_docs import router as warehouse_docs_router
|
||||
|
|
@ -1577,4 +1579,3 @@ Authorization: ApiKey <کلید>
|
|||
|
||||
app = create_app()
|
||||
|
||||
|
||||
|
|
|
|||
338
hesabixAPI/app/services/product_bundle_service.py
Normal file
338
hesabixAPI/app/services/product_bundle_service.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from adapters.api.v1.schema_models.product_bundle import (
|
||||
ProductBundleCreateRequest,
|
||||
ProductBundleItemRequest,
|
||||
ProductBundleUpdateRequest,
|
||||
)
|
||||
from adapters.db.models.product import Product, ProductItemType
|
||||
from adapters.db.models.product_bundle import ProductBundle, ProductBundleItem
|
||||
from app.core.responses import ApiError
|
||||
|
||||
_SORT_COLUMNS = {
|
||||
"id": ProductBundle.id,
|
||||
"name": ProductBundle.name,
|
||||
"is_active": ProductBundle.is_active,
|
||||
"created_at": ProductBundle.created_at,
|
||||
"updated_at": ProductBundle.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _item_type_value(product: Product) -> str:
|
||||
value = product.item_type
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
|
||||
|
||||
def _validate_items(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
items: list[ProductBundleItemRequest],
|
||||
*,
|
||||
require_active: bool,
|
||||
) -> dict[int, Product]:
|
||||
product_ids = [int(item.product_id) for item in items]
|
||||
if len(product_ids) != len(set(product_ids)):
|
||||
raise ApiError(
|
||||
"DUPLICATE_BUNDLE_PRODUCT",
|
||||
"هر کالا فقط یک بار میتواند در یک باندل قرار بگیرد.",
|
||||
http_status=400,
|
||||
)
|
||||
|
||||
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
|
||||
by_id = {int(product.id): product for product in products}
|
||||
missing = [product_id for product_id in product_ids if product_id not in by_id]
|
||||
if missing:
|
||||
raise ApiError(
|
||||
"BUNDLE_PRODUCT_NOT_FOUND",
|
||||
"یک یا چند کالای انتخابشده یافت نشد.",
|
||||
http_status=400,
|
||||
details={"product_ids": missing},
|
||||
)
|
||||
|
||||
wrong_business = [
|
||||
product.id for product in products if int(product.business_id) != int(business_id)
|
||||
]
|
||||
if wrong_business:
|
||||
raise ApiError(
|
||||
"BUNDLE_PRODUCT_NOT_FOUND",
|
||||
"یک یا چند کالای انتخابشده متعلق به این کسبوکار نیست.",
|
||||
http_status=400,
|
||||
)
|
||||
|
||||
services = [
|
||||
product.name
|
||||
for product in products
|
||||
if _item_type_value(product) != ProductItemType.PRODUCT.value
|
||||
]
|
||||
if services:
|
||||
raise ApiError(
|
||||
"BUNDLE_PRODUCTS_ONLY",
|
||||
"باندل فقط میتواند شامل کالا باشد؛ خدمت مجاز نیست.",
|
||||
http_status=400,
|
||||
details={"products": services},
|
||||
)
|
||||
|
||||
if require_active:
|
||||
inactive = [product.name for product in products if not bool(product.is_active)]
|
||||
if inactive:
|
||||
raise ApiError(
|
||||
"BUNDLE_PRODUCT_INACTIVE",
|
||||
"باندل شامل کالای غیرفعال است و قابل افزودن به فاکتور نیست.",
|
||||
http_status=409,
|
||||
details={"products": inactive},
|
||||
)
|
||||
|
||||
return by_id
|
||||
|
||||
|
||||
def _ensure_name_available(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
name: str,
|
||||
*,
|
||||
exclude_bundle_id: int | None = None,
|
||||
) -> None:
|
||||
query = db.query(ProductBundle.id).filter(
|
||||
ProductBundle.business_id == business_id,
|
||||
func.lower(ProductBundle.name) == name.strip().lower(),
|
||||
)
|
||||
if exclude_bundle_id is not None:
|
||||
query = query.filter(ProductBundle.id != exclude_bundle_id)
|
||||
if query.first() is not None:
|
||||
raise ApiError("DUPLICATE_BUNDLE_NAME", "نام باندل تکراری است.", http_status=400)
|
||||
|
||||
|
||||
def _product_dict(product: Product) -> dict[str, Any]:
|
||||
return {
|
||||
"id": product.id,
|
||||
"code": product.code,
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"item_type": _item_type_value(product),
|
||||
"is_active": bool(product.is_active),
|
||||
"main_unit": product.main_unit,
|
||||
"secondary_unit": product.secondary_unit,
|
||||
"unit_conversion_factor": product.unit_conversion_factor,
|
||||
"base_sales_price": product.base_sales_price,
|
||||
"base_sales_note": product.base_sales_note,
|
||||
"sales_price_fx": product.sales_price_fx,
|
||||
"price_fx_currency_id": product.price_fx_currency_id,
|
||||
"is_sales_taxable": bool(product.is_sales_taxable),
|
||||
"sales_tax_rate": product.sales_tax_rate,
|
||||
"track_inventory": bool(product.track_inventory),
|
||||
"inventory_mode": product.inventory_mode,
|
||||
"default_warehouse_id": product.default_warehouse_id,
|
||||
"min_order_qty": product.min_order_qty,
|
||||
}
|
||||
|
||||
|
||||
def to_dict(bundle: ProductBundle, *, include_items: bool = True) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"id": bundle.id,
|
||||
"business_id": bundle.business_id,
|
||||
"name": bundle.name,
|
||||
"description": bundle.description,
|
||||
"is_active": bool(bundle.is_active),
|
||||
"items_count": len(bundle.items),
|
||||
"created_at": bundle.created_at,
|
||||
"updated_at": bundle.updated_at,
|
||||
}
|
||||
if include_items:
|
||||
result["items"] = [
|
||||
{
|
||||
"id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"quantity": item.quantity,
|
||||
"line_order": item.line_order,
|
||||
"product": _product_dict(item.product),
|
||||
}
|
||||
for item in bundle.items
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _get_bundle(db: Session, business_id: int, bundle_id: int) -> ProductBundle | None:
|
||||
return db.scalar(
|
||||
select(ProductBundle)
|
||||
.options(selectinload(ProductBundle.items).selectinload(ProductBundleItem.product))
|
||||
.where(ProductBundle.id == bundle_id, ProductBundle.business_id == business_id)
|
||||
)
|
||||
|
||||
|
||||
def search_bundles(db: Session, business_id: int, query: dict[str, Any]) -> dict[str, Any]:
|
||||
stmt = (
|
||||
select(ProductBundle)
|
||||
.options(selectinload(ProductBundle.items).selectinload(ProductBundleItem.product))
|
||||
.where(ProductBundle.business_id == business_id)
|
||||
)
|
||||
count_stmt = select(func.count(ProductBundle.id)).where(
|
||||
ProductBundle.business_id == business_id
|
||||
)
|
||||
|
||||
search = str(query.get("search") or "").strip()
|
||||
if search:
|
||||
predicate = or_(
|
||||
ProductBundle.name.ilike(f"%{search}%"), ProductBundle.description.ilike(f"%{search}%")
|
||||
)
|
||||
stmt = stmt.where(predicate)
|
||||
count_stmt = count_stmt.where(predicate)
|
||||
|
||||
for raw_filter in query.get("filters") or []:
|
||||
prop = (
|
||||
raw_filter.get("property")
|
||||
if isinstance(raw_filter, dict)
|
||||
else getattr(raw_filter, "property", None)
|
||||
)
|
||||
value = (
|
||||
raw_filter.get("value")
|
||||
if isinstance(raw_filter, dict)
|
||||
else getattr(raw_filter, "value", None)
|
||||
)
|
||||
if prop == "is_active" and value is not None:
|
||||
active = (
|
||||
value
|
||||
if isinstance(value, bool)
|
||||
else str(value).strip().lower() in {"1", "true", "yes"}
|
||||
)
|
||||
stmt = stmt.where(ProductBundle.is_active == active)
|
||||
count_stmt = count_stmt.where(ProductBundle.is_active == active)
|
||||
|
||||
total = int(db.scalar(count_stmt) or 0)
|
||||
take = max(1, min(int(query.get("take") or 20), 100))
|
||||
skip = max(0, int(query.get("skip") or 0))
|
||||
sort_column = _SORT_COLUMNS.get(
|
||||
str(query.get("sort_by") or "updated_at"), ProductBundle.updated_at
|
||||
)
|
||||
sort_desc = bool(query.get("sort_desc", True))
|
||||
stmt = stmt.order_by(
|
||||
sort_column.desc() if sort_desc else sort_column.asc(), ProductBundle.id.desc()
|
||||
)
|
||||
rows = list(db.scalars(stmt.offset(skip).limit(take)).unique().all())
|
||||
page = (skip // take) + 1
|
||||
total_pages = (total + take - 1) // take if total else 0
|
||||
return {
|
||||
"items": [to_dict(row, include_items=False) for row in rows],
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": take,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_bundle(db: Session, business_id: int, bundle_id: int) -> dict[str, Any] | None:
|
||||
bundle = _get_bundle(db, business_id, bundle_id)
|
||||
return to_dict(bundle) if bundle is not None else None
|
||||
|
||||
|
||||
def create_bundle(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
payload: ProductBundleCreateRequest,
|
||||
) -> dict[str, Any]:
|
||||
_validate_items(db, business_id, payload.items, require_active=False)
|
||||
_ensure_name_available(db, business_id, payload.name)
|
||||
bundle = ProductBundle(
|
||||
business_id=business_id,
|
||||
name=payload.name,
|
||||
description=payload.description.strip()
|
||||
if payload.description and payload.description.strip()
|
||||
else None,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
for index, item in enumerate(payload.items, start=1):
|
||||
bundle.items.append(
|
||||
ProductBundleItem(product_id=item.product_id, quantity=item.quantity, line_order=index)
|
||||
)
|
||||
db.add(bundle)
|
||||
db.commit()
|
||||
created = _get_bundle(db, business_id, bundle.id)
|
||||
if created is None:
|
||||
raise ApiError("BUNDLE_NOT_FOUND", "باندل پس از ایجاد یافت نشد.", http_status=500)
|
||||
return to_dict(created)
|
||||
|
||||
|
||||
def update_bundle(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
bundle_id: int,
|
||||
payload: ProductBundleUpdateRequest,
|
||||
) -> dict[str, Any] | None:
|
||||
bundle = _get_bundle(db, business_id, bundle_id)
|
||||
if bundle is None:
|
||||
return None
|
||||
fields = payload.model_fields_set
|
||||
if "name" in fields and payload.name is not None:
|
||||
_ensure_name_available(db, business_id, payload.name, exclude_bundle_id=bundle_id)
|
||||
bundle.name = payload.name
|
||||
if "description" in fields:
|
||||
bundle.description = (
|
||||
payload.description.strip()
|
||||
if payload.description and payload.description.strip()
|
||||
else None
|
||||
)
|
||||
if "is_active" in fields and payload.is_active is not None:
|
||||
bundle.is_active = payload.is_active
|
||||
if "items" in fields and payload.items is not None:
|
||||
_validate_items(db, business_id, payload.items, require_active=False)
|
||||
bundle.items.clear()
|
||||
db.flush()
|
||||
for index, item in enumerate(payload.items, start=1):
|
||||
bundle.items.append(
|
||||
ProductBundleItem(
|
||||
product_id=item.product_id, quantity=item.quantity, line_order=index
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
updated = _get_bundle(db, business_id, bundle_id)
|
||||
if updated is None:
|
||||
raise ApiError("BUNDLE_NOT_FOUND", "باندل پس از ویرایش یافت نشد.", http_status=500)
|
||||
return to_dict(updated)
|
||||
|
||||
|
||||
def delete_bundle(db: Session, business_id: int, bundle_id: int) -> bool:
|
||||
bundle = _get_bundle(db, business_id, bundle_id)
|
||||
if bundle is None:
|
||||
return False
|
||||
db.delete(bundle)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def expand_bundle(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
bundle_id: int,
|
||||
quantity: Decimal,
|
||||
) -> dict[str, Any]:
|
||||
bundle = _get_bundle(db, business_id, bundle_id)
|
||||
if bundle is None:
|
||||
raise ApiError("BUNDLE_NOT_FOUND", "باندل یافت نشد.", http_status=404)
|
||||
if not bundle.is_active:
|
||||
raise ApiError("BUNDLE_INACTIVE", "باندل غیرفعال است.", http_status=409)
|
||||
item_requests = [
|
||||
ProductBundleItemRequest(product_id=item.product_id, quantity=item.quantity)
|
||||
for item in bundle.items
|
||||
]
|
||||
_validate_items(db, business_id, item_requests, require_active=True)
|
||||
multiplier = Decimal(str(quantity))
|
||||
return {
|
||||
"bundle_id": bundle.id,
|
||||
"quantity": multiplier,
|
||||
"items": [
|
||||
{
|
||||
"product_id": item.product_id,
|
||||
"quantity": Decimal(str(item.quantity)) * multiplier,
|
||||
"line_order": item.line_order,
|
||||
"product": _product_dict(item.product),
|
||||
}
|
||||
for item in bundle.items
|
||||
],
|
||||
}
|
||||
|
|
@ -1341,6 +1341,15 @@ def check_product_has_related_documents(db: Session, product_id: int) -> tuple[b
|
|||
if "فرمول تولید (BOM)" not in related_types:
|
||||
related_types.append("فرمول تولید (BOM)")
|
||||
|
||||
# عضویت در باندل کالا (FK: RESTRICT)
|
||||
from adapters.db.models.product_bundle import ProductBundleItem
|
||||
bundle_item_count = db.query(func.count(ProductBundleItem.id)).filter(
|
||||
ProductBundleItem.product_id == product_id
|
||||
).scalar()
|
||||
if bundle_item_count and bundle_item_count > 0:
|
||||
if "باندل کالا" not in related_types:
|
||||
related_types.append("باندل کالا")
|
||||
|
||||
# اسناد هزینه/درآمد کالا (FK: RESTRICT) — باید قبل از حذف چک شود
|
||||
try:
|
||||
from adapters.db.models.goods_expense_income import GoodsExpenseIncomeLine
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
"""افزودن باندلهای کالا برای ورود سریع اقلام فاکتور فروش.
|
||||
|
||||
Revision ID: 20261003_000001_product_bundles
|
||||
Revises: 20260907_000002_distribution_field_ops_upgrade
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "20261003_000001_product_bundles"
|
||||
down_revision = "20260907_000002_distribution_field_ops_upgrade"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"product_bundles",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("business_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["business_id"], ["businesses.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("business_id", "name", name="uq_product_bundles_business_name"),
|
||||
)
|
||||
op.create_index("ix_product_bundles_business_id", "product_bundles", ["business_id"])
|
||||
op.create_index("ix_product_bundles_name", "product_bundles", ["name"])
|
||||
op.create_index("ix_product_bundles_is_active", "product_bundles", ["is_active"])
|
||||
|
||||
op.create_table(
|
||||
"product_bundle_items",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("bundle_id", sa.Integer(), nullable=False),
|
||||
sa.Column("product_id", sa.Integer(), nullable=False),
|
||||
sa.Column("quantity", sa.Numeric(precision=18, scale=6), nullable=False),
|
||||
sa.Column("line_order", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["bundle_id"], ["product_bundles.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="RESTRICT"),
|
||||
sa.CheckConstraint("quantity > 0", name="ck_product_bundle_items_quantity_positive"),
|
||||
sa.CheckConstraint("line_order > 0", name="ck_product_bundle_items_line_order_positive"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("bundle_id", "product_id", name="uq_product_bundle_items_product"),
|
||||
sa.UniqueConstraint("bundle_id", "line_order", name="uq_product_bundle_items_line_order"),
|
||||
)
|
||||
op.create_index("ix_product_bundle_items_bundle_id", "product_bundle_items", ["bundle_id"])
|
||||
op.create_index("ix_product_bundle_items_product_id", "product_bundle_items", ["product_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_product_bundle_items_product_id", table_name="product_bundle_items")
|
||||
op.drop_index("ix_product_bundle_items_bundle_id", table_name="product_bundle_items")
|
||||
op.drop_table("product_bundle_items")
|
||||
op.drop_index("ix_product_bundles_is_active", table_name="product_bundles")
|
||||
op.drop_index("ix_product_bundles_name", table_name="product_bundles")
|
||||
op.drop_index("ix_product_bundles_business_id", table_name="product_bundles")
|
||||
op.drop_table("product_bundles")
|
||||
|
|
@ -152,6 +152,7 @@ abstract final class BusinessNamedRoutes {
|
|||
'business_settings_installments': 'settings/installments',
|
||||
'business_document_monetization': 'document-monetization',
|
||||
'business_product_attributes': 'product-attributes',
|
||||
'business_product_bundles': 'product-bundles',
|
||||
'business_catalog_spec_fields': 'catalog-spec-fields',
|
||||
'business_product_bulk_prices_sheet': 'products/bulk-prices-sheet',
|
||||
'business_products': 'products',
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ import 'pages/business/pnl_cumulative_report_page.dart';
|
|||
import 'pages/business/account_review_report_page.dart';
|
||||
import 'pages/business/persons_page.dart';
|
||||
import 'pages/business/product_attributes_page.dart';
|
||||
import 'pages/business/product_bundles_page.dart';
|
||||
import 'pages/business/catalog_spec_fields_page.dart';
|
||||
import 'pages/business/products_page.dart';
|
||||
import 'pages/business/product_bulk_prices_sheet_page.dart';
|
||||
|
|
@ -5071,6 +5072,21 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'product-bundles',
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(
|
||||
state.pathParameters['business_id']!,
|
||||
);
|
||||
return hesabixNoTransitionPage(
|
||||
state,
|
||||
ProductBundlesPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'catalog-spec-fields',
|
||||
pageBuilder: (context, state) {
|
||||
|
|
|
|||
101
hesabixUI/hesabix_ui/lib/models/product_bundle.dart
Normal file
101
hesabixUI/hesabix_ui/lib/models/product_bundle.dart
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
class ProductBundleItem {
|
||||
final int? id;
|
||||
final int productId;
|
||||
final num quantity;
|
||||
final int lineOrder;
|
||||
final Map<String, dynamic> product;
|
||||
|
||||
const ProductBundleItem({
|
||||
this.id,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.lineOrder,
|
||||
required this.product,
|
||||
});
|
||||
|
||||
factory ProductBundleItem.fromJson(Map<String, dynamic> json) {
|
||||
return ProductBundleItem(
|
||||
id: _asInt(json['id']),
|
||||
productId: _asInt(json['product_id']) ?? 0,
|
||||
quantity: _asNum(json['quantity']) ?? 0,
|
||||
lineOrder: _asInt(json['line_order']) ?? 0,
|
||||
product: json['product'] is Map
|
||||
? Map<String, dynamic>.from(json['product'] as Map)
|
||||
: const <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toRequestJson() => {
|
||||
'product_id': productId,
|
||||
'quantity': quantity,
|
||||
};
|
||||
}
|
||||
|
||||
class ProductBundle {
|
||||
final int id;
|
||||
final int businessId;
|
||||
final String name;
|
||||
final String? description;
|
||||
final bool isActive;
|
||||
final int itemsCount;
|
||||
final List<ProductBundleItem> items;
|
||||
final String? updatedAtDisplay;
|
||||
|
||||
const ProductBundle({
|
||||
required this.id,
|
||||
required this.businessId,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.isActive,
|
||||
required this.itemsCount,
|
||||
this.items = const <ProductBundleItem>[],
|
||||
this.updatedAtDisplay,
|
||||
});
|
||||
|
||||
factory ProductBundle.fromJson(Map<String, dynamic> json) {
|
||||
final rawItems = json['items'];
|
||||
final items = rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
ProductBundleItem.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.toList()
|
||||
: const <ProductBundleItem>[];
|
||||
return ProductBundle(
|
||||
id: _asInt(json['id']) ?? 0,
|
||||
businessId: _asInt(json['business_id']) ?? 0,
|
||||
name: json['name']?.toString() ?? '',
|
||||
description: json['description']?.toString(),
|
||||
isActive: json['is_active'] == true,
|
||||
itemsCount: _asInt(json['items_count']) ?? items.length,
|
||||
items: items,
|
||||
updatedAtDisplay: _displayDate(json['updated_at']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
num? _asNum(dynamic value) {
|
||||
if (value is num) return value;
|
||||
return num.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
String? _displayDate(dynamic value) {
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim().split(' ').first;
|
||||
}
|
||||
if (value is Map) {
|
||||
final raw = value['date_only'] ?? value['formatted'] ?? value['date_time'];
|
||||
if (raw != null && raw.toString().trim().isNotEmpty) {
|
||||
return raw.toString().trim().split(' ').first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1495,6 +1495,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
type: _MenuItemType.simple,
|
||||
hasAddButton: true,
|
||||
),
|
||||
_MenuItem(
|
||||
key: 'product-bundles',
|
||||
label: 'باندلهای کالا',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
selectedIcon: Icons.inventory_2,
|
||||
path: _bu('product-bundles'),
|
||||
type: _MenuItemType.simple,
|
||||
hasAddButton: false,
|
||||
),
|
||||
_MenuItem(
|
||||
key: 'categories',
|
||||
label: t.categories,
|
||||
|
|
@ -3884,6 +3893,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
return 'crm';
|
||||
}
|
||||
if (label == t.products) return 'products';
|
||||
if (label == 'باندلهای کالا' || label == 'Product bundles') return 'products';
|
||||
if (label == t.categories) return 'categories';
|
||||
if (label == t.productAttributes) return 'product_attributes';
|
||||
if (label == t.accounts) return 'bank_accounts';
|
||||
|
|
@ -3988,4 +3998,4 @@ class _MenuItem {
|
|||
hasAddButton: hasAddButton ?? this.hasAddButton,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import '../../widgets/invoice/invoice_info_form.dart';
|
|||
import '../../widgets/invoice/line_items_table.dart';
|
||||
import '../../widgets/invoice/invoice_transactions_widget.dart';
|
||||
import '../../widgets/invoice/invoice_fx_dual_totals_banner.dart';
|
||||
import '../../widgets/invoice/product_bundle_picker_dialog.dart';
|
||||
import '../../utils/number_formatters.dart';
|
||||
import '../../models/invoice_type_model.dart';
|
||||
import '../../models/customer_model.dart';
|
||||
|
|
@ -1015,6 +1016,17 @@ class _EditInvoicePageState extends State<EditInvoicePage> with SingleTickerProv
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_selectedInvoiceType?.value == 'sales') ...[
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _addProductBundleRows,
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('وارد کردن باندل'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
AnimatedBuilder(
|
||||
animation: _tabController,
|
||||
builder: (context, _) {
|
||||
|
|
@ -1147,6 +1159,21 @@ class _EditInvoicePageState extends State<EditInvoicePage> with SingleTickerProv
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _addProductBundleRows() async {
|
||||
final rows = await showProductBundlePickerDialog(
|
||||
context: context,
|
||||
businessId: widget.businessId,
|
||||
);
|
||||
if (!mounted || rows == null || rows.isEmpty) return;
|
||||
setState(() {
|
||||
_lineItems = [
|
||||
..._lineItems.where((item) => item.productId != null),
|
||||
...rows,
|
||||
];
|
||||
_recalculateTotals();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildAdjustmentsTab() {
|
||||
return InvoiceAdjustmentsTabContent(
|
||||
businessId: widget.businessId,
|
||||
|
|
@ -1582,4 +1609,3 @@ class _EditInvoicePageState extends State<EditInvoicePage> with SingleTickerProv
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import '../../models/person_model.dart';
|
|||
import '../../widgets/invoice/line_items_table.dart';
|
||||
import '../../widgets/invoice/invoice_transactions_widget.dart';
|
||||
import '../../widgets/invoice/bom_explosion_widget.dart';
|
||||
import '../../widgets/invoice/product_bundle_picker_dialog.dart';
|
||||
import '../../services/bom_service.dart';
|
||||
import '../../widgets/invoice/warehouse_combobox_widget.dart';
|
||||
import '../../widgets/invoice/invoice_fx_dual_totals_banner.dart';
|
||||
|
|
@ -3214,6 +3215,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_selectedInvoiceType?.value == 'sales') ...[
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _addProductBundleRows,
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('وارد کردن باندل'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// ویجت انفجار فرمول (فقط برای فاکتور تولید)
|
||||
if (_selectedInvoiceType == InvoiceType.production) ...[
|
||||
BomExplosionWidget(
|
||||
|
|
@ -3408,6 +3420,21 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _addProductBundleRows() async {
|
||||
final rows = await showProductBundlePickerDialog(
|
||||
context: context,
|
||||
businessId: widget.businessId,
|
||||
);
|
||||
if (!mounted || rows == null || rows.isEmpty) return;
|
||||
setState(() {
|
||||
_lineItems = [
|
||||
..._lineItems.where((item) => item.productId != null),
|
||||
...rows,
|
||||
];
|
||||
_recalculateTotalsFromLines();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildAdjustmentsTab() {
|
||||
return InvoiceAdjustmentsTabContent(
|
||||
businessId: widget.businessId,
|
||||
|
|
@ -3879,4 +3906,4 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../models/product_bundle.dart';
|
||||
import '../../services/product_bundle_service.dart';
|
||||
import '../../utils/error_extractor.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/data_table/data_table_config.dart';
|
||||
import '../../widgets/data_table/data_table_widget.dart';
|
||||
import '../../widgets/permission/permission_widgets.dart';
|
||||
import '../../widgets/product/product_bundle_form_dialog.dart';
|
||||
|
||||
class ProductBundlesPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
final AuthStore authStore;
|
||||
|
||||
const ProductBundlesPage({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.authStore,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProductBundlesPage> createState() => _ProductBundlesPageState();
|
||||
}
|
||||
|
||||
class _ProductBundlesPageState extends State<ProductBundlesPage> {
|
||||
final ProductBundleService _service = ProductBundleService();
|
||||
final GlobalKey _tableKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.authStore.hasBusinessPermission('products', 'view')) {
|
||||
return const AccessDeniedPage();
|
||||
}
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: DataTableWidget<ProductBundle>(
|
||||
key: _tableKey,
|
||||
config: _config(),
|
||||
fromJson: ProductBundle.fromJson,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DataTableConfig<ProductBundle> _config() {
|
||||
return DataTableConfig<ProductBundle>(
|
||||
endpoint: '/api/v1/product-bundles/business/${widget.businessId}/search',
|
||||
businessId: widget.businessId,
|
||||
title: 'باندلهای کالا',
|
||||
showBackButton: true,
|
||||
onBack: () {
|
||||
if (context.canPop()) context.pop();
|
||||
},
|
||||
showTableIcon: false,
|
||||
columns: [
|
||||
TextColumn(
|
||||
'name',
|
||||
'نام باندل',
|
||||
width: ColumnWidth.large,
|
||||
formatter: (item) => item.name,
|
||||
),
|
||||
TextColumn(
|
||||
'description',
|
||||
'توضیحات',
|
||||
width: ColumnWidth.extraLarge,
|
||||
formatter: (item) => item.description?.trim().isNotEmpty == true
|
||||
? item.description
|
||||
: '—',
|
||||
),
|
||||
NumberColumn(
|
||||
'items_count',
|
||||
'تعداد اقلام',
|
||||
formatter: (item) => item.itemsCount.toString(),
|
||||
),
|
||||
TextColumn(
|
||||
'is_active',
|
||||
'وضعیت',
|
||||
sortable: true,
|
||||
searchable: false,
|
||||
formatter: (item) => item.isActive ? 'فعال' : 'غیرفعال',
|
||||
),
|
||||
DateColumn(
|
||||
'updated_at',
|
||||
'آخرین تغییر',
|
||||
formatter: (item) => item.updatedAtDisplay ?? '—',
|
||||
),
|
||||
ActionColumn(
|
||||
'actions',
|
||||
'عملیات',
|
||||
actions: [
|
||||
DataTableAction(
|
||||
icon: Icons.edit_outlined,
|
||||
label: 'ویرایش',
|
||||
enabled: (_) =>
|
||||
widget.authStore.hasBusinessPermission('products', 'edit'),
|
||||
onTap: (item) => _edit(item as ProductBundle),
|
||||
),
|
||||
DataTableAction(
|
||||
icon: Icons.delete_outline,
|
||||
label: 'حذف',
|
||||
isDestructive: true,
|
||||
enabled: (_) =>
|
||||
widget.authStore.hasBusinessPermission('products', 'delete'),
|
||||
onTap: (item) => _delete(item as ProductBundle),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
searchFields: const ['name', 'description'],
|
||||
filterFields: const ['is_active'],
|
||||
defaultPageSize: 20,
|
||||
expandBodyHeightToFitRows: true,
|
||||
customHeaderActions: [
|
||||
PermissionButton(
|
||||
section: 'products',
|
||||
action: 'add',
|
||||
authStore: widget.authStore,
|
||||
child: Tooltip(
|
||||
message: 'باندل جدید',
|
||||
child: IconButton(onPressed: _create, icon: const Icon(Icons.add)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final changed = await showProductBundleFormDialog(
|
||||
context: context,
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
);
|
||||
if (changed == true) {
|
||||
_refresh();
|
||||
if (mounted) SnackBarHelper.show(context, message: 'باندل ساخته شد.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _edit(ProductBundle row) async {
|
||||
try {
|
||||
final bundle = await _service.getOne(
|
||||
businessId: widget.businessId,
|
||||
bundleId: row.id,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final changed = await showProductBundleFormDialog(
|
||||
context: context,
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
bundle: bundle,
|
||||
);
|
||||
if (changed == true) {
|
||||
_refresh();
|
||||
if (mounted) SnackBarHelper.show(context, message: 'باندل ویرایش شد.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: ErrorExtractor.forContext(error, context),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(ProductBundle row) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('حذف باندل'),
|
||||
content: Text(
|
||||
'باندل «${row.name}» حذف شود؟ این کار روی فاکتورهای قبلی اثری ندارد.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('انصراف'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('حذف'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await _service.delete(businessId: widget.businessId, bundleId: row.id);
|
||||
_refresh();
|
||||
if (mounted) SnackBarHelper.show(context, message: 'باندل حذف شد.');
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: ErrorExtractor.forContext(error, context),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final state = _tableKey.currentState;
|
||||
if (mounted && state != null) {
|
||||
// ignore: avoid_dynamic_calls
|
||||
(state as dynamic).refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
102
hesabixUI/hesabix_ui/lib/services/product_bundle_service.dart
Normal file
102
hesabixUI/hesabix_ui/lib/services/product_bundle_service.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import '../core/api_client.dart';
|
||||
import '../models/product_bundle.dart';
|
||||
|
||||
class ProductBundleService {
|
||||
final ApiClient _api;
|
||||
|
||||
ProductBundleService({ApiClient? apiClient})
|
||||
: _api = apiClient ?? ApiClient();
|
||||
|
||||
Future<List<ProductBundle>> search({
|
||||
required int businessId,
|
||||
String? query,
|
||||
bool? isActive,
|
||||
int take = 100,
|
||||
int skip = 0,
|
||||
}) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/product-bundles/business/$businessId/search',
|
||||
data: {
|
||||
'take': take,
|
||||
'skip': skip,
|
||||
if (query != null && query.trim().isNotEmpty) 'search': query.trim(),
|
||||
if (isActive != null)
|
||||
'filters': [
|
||||
{'property': 'is_active', 'operator': '=', 'value': isActive},
|
||||
],
|
||||
},
|
||||
);
|
||||
final data = response.data?['data'];
|
||||
final items = data is Map ? data['items'] : null;
|
||||
if (items is! List) return const <ProductBundle>[];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => ProductBundle.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<ProductBundle> getOne({
|
||||
required int businessId,
|
||||
required int bundleId,
|
||||
}) async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'/api/v1/product-bundles/business/$businessId/$bundleId',
|
||||
);
|
||||
return ProductBundle.fromJson(
|
||||
Map<String, dynamic>.from(response.data?['data'] as Map? ?? const {}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProductBundle> create({
|
||||
required int businessId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/product-bundles/business/$businessId',
|
||||
data: payload,
|
||||
);
|
||||
return ProductBundle.fromJson(
|
||||
Map<String, dynamic>.from(response.data?['data'] as Map? ?? const {}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProductBundle> update({
|
||||
required int businessId,
|
||||
required int bundleId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _api.put<Map<String, dynamic>>(
|
||||
'/api/v1/product-bundles/business/$businessId/$bundleId',
|
||||
data: payload,
|
||||
);
|
||||
return ProductBundle.fromJson(
|
||||
Map<String, dynamic>.from(response.data?['data'] as Map? ?? const {}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete({required int businessId, required int bundleId}) async {
|
||||
await _api.delete<Map<String, dynamic>>(
|
||||
'/api/v1/product-bundles/business/$businessId/$bundleId',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ProductBundleItem>> expand({
|
||||
required int businessId,
|
||||
required int bundleId,
|
||||
required num quantity,
|
||||
}) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'/api/v1/product-bundles/business/$businessId/$bundleId/expand',
|
||||
data: {'quantity': quantity},
|
||||
);
|
||||
final data = response.data?['data'];
|
||||
final items = data is Map ? data['items'] : null;
|
||||
if (items is! List) return const <ProductBundleItem>[];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => ProductBundleItem.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -772,10 +772,21 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
await _loadProductInfo(pid, force: true);
|
||||
}
|
||||
_hydrateProductMetadataWithoutChangingUnitPrice();
|
||||
for (var i = 0; i < _rows.length; i++) {
|
||||
final row = _rows[i];
|
||||
if (row.extraInfo?['_local_resolve_unit_price'] != true) continue;
|
||||
final resolved = await _resolveUnitPrice(row, preferManual: false);
|
||||
final cleanedExtraInfo = Map<String, dynamic>.from(
|
||||
resolved.extraInfo ?? const <String, dynamic>{},
|
||||
)..remove('_local_resolve_unit_price');
|
||||
_rows[i] = resolved.copyWith(extraInfo: cleanedExtraInfo);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_syncAllDescriptionControllersFromRows();
|
||||
}
|
||||
_notify();
|
||||
_invoiceLineAttrsLog('loadProductInfosForInitialRows done');
|
||||
}
|
||||
|
||||
|
|
@ -838,7 +849,12 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
}
|
||||
_loadProductInfosForInitialRows();
|
||||
_notify();
|
||||
} else if (widget.initialRows!.length > _rows.length) {
|
||||
} else if (widget.initialRows!.length > _rows.length ||
|
||||
widget.initialRows!.any(
|
||||
(incoming) =>
|
||||
incoming.extraInfo?['_local_resolve_unit_price'] == true &&
|
||||
!_rows.any((current) => current.lineKey == incoming.lineKey),
|
||||
)) {
|
||||
_invoiceLineAttrsLog(
|
||||
'didUpdateWidget: replace rows initial=${widget.initialRows!.length} current=${_rows.length}',
|
||||
);
|
||||
|
|
@ -3014,4 +3030,3 @@ class _QuantityWithUnitFieldState extends State<_QuantityWithUnitField> {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/invoice_line_item.dart';
|
||||
import '../../models/product_bundle.dart';
|
||||
import '../../services/product_bundle_service.dart';
|
||||
import '../../utils/error_extractor.dart';
|
||||
import '../../utils/number_normalizer.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
|
||||
Future<List<InvoiceLineItem>?> showProductBundlePickerDialog({
|
||||
required BuildContext context,
|
||||
required int businessId,
|
||||
}) {
|
||||
return showDialog<List<InvoiceLineItem>>(
|
||||
context: context,
|
||||
builder: (_) => _ProductBundlePickerDialog(businessId: businessId),
|
||||
);
|
||||
}
|
||||
|
||||
class _ProductBundlePickerDialog extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
||||
const _ProductBundlePickerDialog({required this.businessId});
|
||||
|
||||
@override
|
||||
State<_ProductBundlePickerDialog> createState() =>
|
||||
_ProductBundlePickerDialogState();
|
||||
}
|
||||
|
||||
class _ProductBundlePickerDialogState
|
||||
extends State<_ProductBundlePickerDialog> {
|
||||
final ProductBundleService _service = ProductBundleService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _quantityController = TextEditingController(
|
||||
text: '1',
|
||||
);
|
||||
List<ProductBundle> _bundles = const [];
|
||||
ProductBundle? _selected;
|
||||
bool _loading = true;
|
||||
bool _loadingDetails = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBundles();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_quantityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadBundles([String? query]) async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final bundles = await _service.search(
|
||||
businessId: widget.businessId,
|
||||
query: query,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _bundles = bundles);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: ErrorExtractor.forContext(error, context),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectBundle(ProductBundle bundle) async {
|
||||
setState(() {
|
||||
_selected = bundle;
|
||||
_loadingDetails = true;
|
||||
});
|
||||
try {
|
||||
final details = await _service.getOne(
|
||||
businessId: widget.businessId,
|
||||
bundleId: bundle.id,
|
||||
);
|
||||
if (mounted && _selected?.id == bundle.id) {
|
||||
setState(() => _selected = details);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: ErrorExtractor.forContext(error, context),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted && _selected?.id == bundle.id) {
|
||||
setState(() => _loadingDetails = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final selected = _selected;
|
||||
if (selected == null) {
|
||||
SnackBarHelper.show(context, message: 'یک باندل را انتخاب کنید.');
|
||||
return;
|
||||
}
|
||||
final multiplier = num.tryParse(
|
||||
toEnglishDigits(_quantityController.text.trim()),
|
||||
);
|
||||
if (multiplier == null || multiplier <= 0) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: 'تعداد باندل باید بیشتر از صفر باشد.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final items = await _service.expand(
|
||||
businessId: widget.businessId,
|
||||
bundleId: selected.id,
|
||||
quantity: multiplier,
|
||||
);
|
||||
final lines = items.map(_toInvoiceLine).toList();
|
||||
if (mounted) Navigator.of(context).pop(lines);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: ErrorExtractor.forContext(error, context),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
InvoiceLineItem _toInvoiceLine(ProductBundleItem item) {
|
||||
final product = item.product;
|
||||
final taxable = product['is_sales_taxable'] == true;
|
||||
final configuredTaxRate = _asNum(product['sales_tax_rate']) ?? 0;
|
||||
final salesNote = product['base_sales_note']?.toString().trim() ?? '';
|
||||
final description = product['description']?.toString().trim() ?? '';
|
||||
return InvoiceLineItem(
|
||||
productId: item.productId,
|
||||
productCode: product['code']?.toString(),
|
||||
productName: product['name']?.toString(),
|
||||
mainUnit: product['main_unit']?.toString(),
|
||||
secondaryUnit: product['secondary_unit']?.toString(),
|
||||
unitConversionFactor: _asNum(product['unit_conversion_factor']) ?? 1,
|
||||
selectedUnit: product['main_unit']?.toString(),
|
||||
quantity: item.quantity,
|
||||
unitPriceSource: 'base',
|
||||
unitPrice: 0,
|
||||
baseSalesPriceMainUnit: _asNum(product['base_sales_price']),
|
||||
salesPriceFxMainUnit: _asNum(product['sales_price_fx']),
|
||||
priceFxCurrencyId: _asInt(product['price_fx_currency_id']),
|
||||
taxRate: taxable ? (configuredTaxRate > 0 ? configuredTaxRate : 9) : 0,
|
||||
minOrderQty: _asInt(product['min_order_qty']),
|
||||
trackInventory: product['track_inventory'] == true,
|
||||
warehouseId: _asInt(product['default_warehouse_id']),
|
||||
description: salesNote.isNotEmpty
|
||||
? salesNote
|
||||
: (description.isNotEmpty ? description : null),
|
||||
extraInfo: const {'_local_resolve_unit_price': true},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: const Text('وارد کردن باندل کالا'),
|
||||
content: SizedBox(
|
||||
width: 720,
|
||||
height: 560,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'جستوجوی باندل',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: 'جستوجو',
|
||||
onPressed: () => _loadBundles(_searchController.text),
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: _loadBundles,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _bundles.isEmpty
|
||||
? const Center(child: Text('باندل فعالی یافت نشد.'))
|
||||
: ListView.separated(
|
||||
itemCount: _bundles.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final bundle = _bundles[index];
|
||||
return ListTile(
|
||||
selected: _selected?.id == bundle.id,
|
||||
title: Text(bundle.name),
|
||||
subtitle: Text(
|
||||
'${bundle.itemsCount} قلم کالا',
|
||||
),
|
||||
trailing: _selected?.id == bundle.id
|
||||
? const Icon(Icons.check_circle)
|
||||
: null,
|
||||
onTap: () => _selectBundle(bundle),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: _selected == null
|
||||
? const Center(
|
||||
child: Text('یک باندل را انتخاب کنید.'),
|
||||
)
|
||||
: _loadingDetails
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_selected!.name,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
if (_selected!.description
|
||||
?.trim()
|
||||
.isNotEmpty ==
|
||||
true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_selected!.description!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _selected!.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _selected!.items[index];
|
||||
final product = item.product;
|
||||
final code =
|
||||
product['code']?.toString() ?? '';
|
||||
final name =
|
||||
product['name']?.toString() ?? '';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
code.isEmpty
|
||||
? name
|
||||
: '$code - $name',
|
||||
),
|
||||
trailing: Text(
|
||||
'× ${_formatQuantity(item.quantity)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _quantityController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: const [EnglishDigitsFormatter()],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'تعداد باندل',
|
||||
helperText: 'تعداد هر کالا در این ضریب ضرب میشود.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('انصراف'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _submitting || _loadingDetails ? null : _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.playlist_add),
|
||||
label: const Text('افزودن ردیفها'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
num? _asNum(dynamic value) {
|
||||
if (value is num) return value;
|
||||
return num.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
String _formatQuantity(num value) {
|
||||
if (value.remainder(1) == 0) return value.toInt().toString();
|
||||
return value.toString();
|
||||
}
|
||||
|
|
@ -325,6 +325,7 @@ class ProductComboboxWidget extends StatefulWidget {
|
|||
final String hintText;
|
||||
final AuthStore? authStore;
|
||||
final ValueChanged<List<Map<String, dynamic>>>? onProductsLoaded;
|
||||
final bool productsOnly;
|
||||
|
||||
const ProductComboboxWidget({
|
||||
super.key,
|
||||
|
|
@ -335,6 +336,7 @@ class ProductComboboxWidget extends StatefulWidget {
|
|||
this.hintText = 'جستوجو و انتخاب کالا/خدمت',
|
||||
this.authStore,
|
||||
this.onProductsLoaded,
|
||||
this.productsOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -342,8 +344,16 @@ class ProductComboboxWidget extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
||||
List<Map<String, dynamic>>? get _typeFilters => widget.productsOnly
|
||||
? const [
|
||||
{'property': 'item_type', 'operator': '=', 'value': 'کالا'},
|
||||
{'property': 'is_active', 'operator': '=', 'value': true},
|
||||
]
|
||||
: null;
|
||||
|
||||
/// افزودن کالای جدید در API نیازمند `products.add` است؛ صرفاً وجود [AuthStore] کافی نیست.
|
||||
bool get _canCreateProducts =>
|
||||
!widget.productsOnly &&
|
||||
widget.authStore != null &&
|
||||
widget.authStore!.hasBusinessPermission('products', 'add');
|
||||
|
||||
|
|
@ -922,6 +932,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
limit: _pageSize,
|
||||
skip: 0,
|
||||
searchFields: const ['code', 'name', 'barcode'],
|
||||
filters: _typeFilters,
|
||||
categoryIds: categoryIds.isNotEmpty ? categoryIds : null,
|
||||
includeInventory: true,
|
||||
);
|
||||
|
|
@ -1008,6 +1019,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
limit: _pageSize,
|
||||
skip: 0,
|
||||
searchFields: const ['code', 'name', 'barcode'],
|
||||
filters: _typeFilters,
|
||||
categoryIds: categoryIds.isNotEmpty ? categoryIds : null,
|
||||
includeInventory: true,
|
||||
);
|
||||
|
|
@ -1053,6 +1065,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
limit: _pageSize,
|
||||
skip: _currentSkip,
|
||||
searchFields: const ['code', 'name', 'barcode'],
|
||||
filters: _typeFilters,
|
||||
categoryIds: categoryIds.isNotEmpty ? categoryIds : null,
|
||||
includeInventory: true,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../models/product_bundle.dart';
|
||||
import '../../services/product_bundle_service.dart';
|
||||
import '../../utils/error_extractor.dart';
|
||||
import '../../utils/number_normalizer.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../invoice/product_combobox_widget.dart';
|
||||
|
||||
Future<bool?> showProductBundleFormDialog({
|
||||
required BuildContext context,
|
||||
required int businessId,
|
||||
required AuthStore authStore,
|
||||
ProductBundle? bundle,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _ProductBundleFormDialog(
|
||||
businessId: businessId,
|
||||
authStore: authStore,
|
||||
bundle: bundle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _BundleDraftItem {
|
||||
final Map<String, dynamic> product;
|
||||
final TextEditingController quantityController;
|
||||
|
||||
_BundleDraftItem({required this.product, required num quantity})
|
||||
: quantityController = TextEditingController(
|
||||
text: _formatQuantity(quantity),
|
||||
);
|
||||
|
||||
int get productId => _asInt(product['id']) ?? 0;
|
||||
|
||||
void dispose() => quantityController.dispose();
|
||||
}
|
||||
|
||||
class _ProductBundleFormDialog extends StatefulWidget {
|
||||
final int businessId;
|
||||
final AuthStore authStore;
|
||||
final ProductBundle? bundle;
|
||||
|
||||
const _ProductBundleFormDialog({
|
||||
required this.businessId,
|
||||
required this.authStore,
|
||||
this.bundle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ProductBundleFormDialog> createState() =>
|
||||
_ProductBundleFormDialogState();
|
||||
}
|
||||
|
||||
class _ProductBundleFormDialogState extends State<_ProductBundleFormDialog> {
|
||||
final ProductBundleService _service = ProductBundleService();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late bool _isActive;
|
||||
late final List<_BundleDraftItem> _items;
|
||||
bool _saving = false;
|
||||
int _pickerRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.bundle?.name ?? '');
|
||||
_descriptionController = TextEditingController(
|
||||
text: widget.bundle?.description ?? '',
|
||||
);
|
||||
_isActive = widget.bundle?.isActive ?? true;
|
||||
_items = (widget.bundle?.items ?? const <ProductBundleItem>[])
|
||||
.map(
|
||||
(item) =>
|
||||
_BundleDraftItem(product: item.product, quantity: item.quantity),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
for (final item in _items) {
|
||||
item.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _addProduct(Map<String, dynamic>? product) {
|
||||
if (product == null) return;
|
||||
final productId = _asInt(product['id']);
|
||||
if (productId == null) return;
|
||||
if (_items.any((item) => item.productId == productId)) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: 'این کالا قبلاً به باندل اضافه شده است.',
|
||||
);
|
||||
setState(() => _pickerRevision++);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_items.add(_BundleDraftItem(product: product, quantity: 1));
|
||||
_pickerRevision++;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
SnackBarHelper.show(context, message: 'نام باندل را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: 'حداقل یک کالا به باندل اضافه کنید.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final requestItems = <Map<String, dynamic>>[];
|
||||
for (final item in _items) {
|
||||
final quantity = num.tryParse(
|
||||
toEnglishDigits(item.quantityController.text.trim()),
|
||||
);
|
||||
if (quantity == null || quantity <= 0) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: 'تعداد همهٔ کالاها باید بیشتر از صفر باشد.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
requestItems.add({'product_id': item.productId, 'quantity': quantity});
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final payload = <String, dynamic>{
|
||||
'name': name,
|
||||
'description': _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
'is_active': _isActive,
|
||||
'items': requestItems,
|
||||
};
|
||||
if (widget.bundle == null) {
|
||||
await _service.create(businessId: widget.businessId, payload: payload);
|
||||
} else {
|
||||
await _service.update(
|
||||
businessId: widget.businessId,
|
||||
bundleId: widget.bundle!.id,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
SnackBarHelper.show(
|
||||
context,
|
||||
message: ErrorExtractor.forContext(error, context),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(widget.bundle == null ? 'باندل جدید' : 'ویرایش باندل'),
|
||||
content: SizedBox(
|
||||
width: 760,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 255,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'نام باندل',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'توضیحات (اختیاری)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('فعال'),
|
||||
subtitle: const Text(
|
||||
'فقط باندل فعال را میتوان به فاکتور افزود.',
|
||||
),
|
||||
value: _isActive,
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (value) => setState(() => _isActive = value),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
Text('کالاهای باندل', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
ProductComboboxWidget(
|
||||
key: ValueKey(_pickerRevision),
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
productsOnly: true,
|
||||
label: 'افزودن کالا',
|
||||
hintText: 'کالا را جستوجو و انتخاب کنید',
|
||||
onChanged: _addProduct,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (_items.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'هنوز کالایی اضافه نشده است.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
..._items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final code = item.product['code']?.toString() ?? '';
|
||||
final name = item.product['name']?.toString() ?? '';
|
||||
final unit = item.product['main_unit']?.toString() ?? '';
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
code.isEmpty ? name : '$code - $name',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: TextField(
|
||||
controller: item.quantityController,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: const [EnglishDigitsFormatter()],
|
||||
decoration: InputDecoration(
|
||||
labelText: unit.isEmpty
|
||||
? 'تعداد'
|
||||
: 'تعداد ($unit)',
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'حذف از باندل',
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () {
|
||||
setState(() => _items.removeAt(index));
|
||||
item.dispose();
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(false),
|
||||
child: const Text('انصراف'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: const Text('ذخیره'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
String _formatQuantity(num value) {
|
||||
if (value.remainder(1) == 0) return value.toInt().toString();
|
||||
return value.toString();
|
||||
}
|
||||
Loading…
Reference in a new issue