diff --git a/hesabixAPI/adapters/api/v1/product_bundles.py b/hesabixAPI/adapters/api/v1/product_bundles.py new file mode 100644 index 00000000..99f4564b --- /dev/null +++ b/hesabixAPI/adapters/api/v1/product_bundles.py @@ -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) diff --git a/hesabixAPI/adapters/api/v1/schema_models/product_bundle.py b/hesabixAPI/adapters/api/v1/schema_models/product_bundle.py new file mode 100644 index 00000000..67fb9da7 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/product_bundle.py @@ -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) diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index 26caec62..fed7aa59 100755 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -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 diff --git a/hesabixAPI/adapters/db/models/product_bundle.py b/hesabixAPI/adapters/db/models/product_bundle.py new file mode 100644 index 00000000..f3ba8cfd --- /dev/null +++ b/hesabixAPI/adapters/db/models/product_bundle.py @@ -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") diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index ba01d2ec..c2f45a2c 100755 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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() - diff --git a/hesabixAPI/app/services/product_bundle_service.py b/hesabixAPI/app/services/product_bundle_service.py new file mode 100644 index 00000000..d092c2e4 --- /dev/null +++ b/hesabixAPI/app/services/product_bundle_service.py @@ -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 + ], + } diff --git a/hesabixAPI/app/services/product_service.py b/hesabixAPI/app/services/product_service.py index 8496f52b..d78a2fb8 100755 --- a/hesabixAPI/app/services/product_service.py +++ b/hesabixAPI/app/services/product_service.py @@ -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 diff --git a/hesabixAPI/migrations/versions/20261003_000001_product_bundles.py b/hesabixAPI/migrations/versions/20261003_000001_product_bundles.py new file mode 100644 index 00000000..7c8c34de --- /dev/null +++ b/hesabixAPI/migrations/versions/20261003_000001_product_bundles.py @@ -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") diff --git a/hesabixUI/hesabix_ui/lib/core/business_named_route_locations.dart b/hesabixUI/hesabix_ui/lib/core/business_named_route_locations.dart index 25e7d6e8..405ff34f 100755 --- a/hesabixUI/hesabix_ui/lib/core/business_named_route_locations.dart +++ b/hesabixUI/hesabix_ui/lib/core/business_named_route_locations.dart @@ -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', diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index d516c951..29bd0dcd 100755 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -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 { ); }, ), + 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) { diff --git a/hesabixUI/hesabix_ui/lib/models/product_bundle.dart b/hesabixUI/hesabix_ui/lib/models/product_bundle.dart new file mode 100644 index 00000000..409ccf77 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/product_bundle.dart @@ -0,0 +1,101 @@ +class ProductBundleItem { + final int? id; + final int productId; + final num quantity; + final int lineOrder; + final Map product; + + const ProductBundleItem({ + this.id, + required this.productId, + required this.quantity, + required this.lineOrder, + required this.product, + }); + + factory ProductBundleItem.fromJson(Map 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.from(json['product'] as Map) + : const {}, + ); + } + + Map 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 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 [], + this.updatedAtDisplay, + }); + + factory ProductBundle.fromJson(Map json) { + final rawItems = json['items']; + final items = rawItems is List + ? rawItems + .whereType() + .map( + (item) => + ProductBundleItem.fromJson(Map.from(item)), + ) + .toList() + : const []; + 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; +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index c620d539..ebb6360f 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -1495,6 +1495,15 @@ class _BusinessShellState extends State { 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 { 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, ); } -} \ No newline at end of file +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart index 02d6da56..0f4ee01e 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart @@ -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 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 with SingleTickerProv ); } + Future _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 with SingleTickerProv } } - diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart index eeab2697..7a596056 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart @@ -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 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 with SingleTickerProvid ); } + Future _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 with SingleTickerProvid ), ); } -} \ No newline at end of file +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/product_bundles_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/product_bundles_page.dart new file mode 100644 index 00000000..839f2352 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/product_bundles_page.dart @@ -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 createState() => _ProductBundlesPageState(); +} + +class _ProductBundlesPageState extends State { + 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( + key: _tableKey, + config: _config(), + fromJson: ProductBundle.fromJson, + ), + ), + ); + } + + DataTableConfig _config() { + return DataTableConfig( + 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 _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 _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 _delete(ProductBundle row) async { + final confirmed = await showDialog( + 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(); + } + }); + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/product_bundle_service.dart b/hesabixUI/hesabix_ui/lib/services/product_bundle_service.dart new file mode 100644 index 00000000..1b2f41bf --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/product_bundle_service.dart @@ -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> search({ + required int businessId, + String? query, + bool? isActive, + int take = 100, + int skip = 0, + }) async { + final response = await _api.post>( + '/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 []; + return items + .whereType() + .map((item) => ProductBundle.fromJson(Map.from(item))) + .toList(); + } + + Future getOne({ + required int businessId, + required int bundleId, + }) async { + final response = await _api.get>( + '/api/v1/product-bundles/business/$businessId/$bundleId', + ); + return ProductBundle.fromJson( + Map.from(response.data?['data'] as Map? ?? const {}), + ); + } + + Future create({ + required int businessId, + required Map payload, + }) async { + final response = await _api.post>( + '/api/v1/product-bundles/business/$businessId', + data: payload, + ); + return ProductBundle.fromJson( + Map.from(response.data?['data'] as Map? ?? const {}), + ); + } + + Future update({ + required int businessId, + required int bundleId, + required Map payload, + }) async { + final response = await _api.put>( + '/api/v1/product-bundles/business/$businessId/$bundleId', + data: payload, + ); + return ProductBundle.fromJson( + Map.from(response.data?['data'] as Map? ?? const {}), + ); + } + + Future delete({required int businessId, required int bundleId}) async { + await _api.delete>( + '/api/v1/product-bundles/business/$businessId/$bundleId', + ); + } + + Future> expand({ + required int businessId, + required int bundleId, + required num quantity, + }) async { + final response = await _api.post>( + '/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 []; + return items + .whereType() + .map( + (item) => ProductBundleItem.fromJson(Map.from(item)), + ) + .toList(); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart index fb567ad1..776ae293 100755 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart @@ -772,10 +772,21 @@ class _InvoiceLineItemsTableState extends State { 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.from( + resolved.extraInfo ?? const {}, + )..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 { } _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> { } - diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_bundle_picker_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_bundle_picker_dialog.dart new file mode 100644 index 00000000..361f8b39 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_bundle_picker_dialog.dart @@ -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?> showProductBundlePickerDialog({ + required BuildContext context, + required int businessId, +}) { + return showDialog>( + 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 _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 _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 _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 _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(); +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart index 9d19243d..509f2d47 100755 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart @@ -325,6 +325,7 @@ class ProductComboboxWidget extends StatefulWidget { final String hintText; final AuthStore? authStore; final ValueChanged>>? 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 { + List>? 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 { 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 { 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 { limit: _pageSize, skip: _currentSkip, searchFields: const ['code', 'name', 'barcode'], + filters: _typeFilters, categoryIds: categoryIds.isNotEmpty ? categoryIds : null, includeInventory: true, ); diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_bundle_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_bundle_form_dialog.dart new file mode 100644 index 00000000..1442d295 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_bundle_form_dialog.dart @@ -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 showProductBundleFormDialog({ + required BuildContext context, + required int businessId, + required AuthStore authStore, + ProductBundle? bundle, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ProductBundleFormDialog( + businessId: businessId, + authStore: authStore, + bundle: bundle, + ), + ); +} + +class _BundleDraftItem { + final Map 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 []) + .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? 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 _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 = >[]; + 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 = { + '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(); +}