feat(products): add product bundles to sales invoices #11

Merged
morrning merged 1 commit from Seyyed/Seyyed_arc:codex/product-bundles into master 2026-10-03 21:51:11 +03:30
20 changed files with 1888 additions and 6 deletions

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

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

View file

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

View 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")

View file

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

View 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
],
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View 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();
}
}

View file

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

View file

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

View file

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

View file

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