Feature: Added product bundles. Have to wire it up to inventory and reports.

This commit is contained in:
tanshu committed 2026-01-31 15:16:58 +00:00
1 parent b79af6e1c6
commit 6e8843d3c4
48 files changed
+2139 -130

No files matched your search

@@ -0,0 +1,103 @@
"""bundles
Revision ID: 367ecf7b898f
Revises: 1e0daf6bc1ae
Create Date: 2026-01-28 16:11:21.874409
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision = "367ecf7b898f"
down_revision = "1e0daf6bc1ae"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"bundle_items",
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("bundle_id", sa.Uuid(), nullable=False),
sa.Column("item_id", sa.Uuid(), nullable=False),
sa.Column("quantity", sa.Numeric(precision=15, scale=2), nullable=False),
sa.Column("sale_price", sa.Numeric(precision=15, scale=2), nullable=False),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_till", sa.Date(), nullable=True),
postgresql.ExcludeConstraint(
(sa.column("bundle_id"), "="),
(sa.column("item_id"), "="),
(sa.text("daterange(valid_from, valid_till, '[]')"), "&&"),
using="gist",
name=op.f("uq_bundle_items_bundle_id_item_id"),
),
sa.ForeignKeyConstraint(
["bundle_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_bundle_id_stock_keeping_units")
),
sa.ForeignKeyConstraint(
["item_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_item_id_stock_keeping_units")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_bundle_items")),
)
inventory_type = sa.Enum("regular", "bundle", "bundle_item", name="inventorytype")
# inventory_type.create(op.get_bind())
op.add_column("inventories", sa.Column("type", inventory_type, server_default=sa.text("'regular'"), nullable=False))
op.add_column("inventories", sa.Column("parent_id", sa.Uuid(), nullable=True))
op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique")
op.create_unique_constraint(
op.f("uq_inventories_kot_id"),
"inventories",
["kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"],
postgresql_nulls_not_distinct=True,
)
op.create_foreign_key(
op.f("fk_inventories_parent_id_inventories"), "inventories", "inventories", ["parent_id"], ["id"]
)
op.add_column(
"stock_keeping_units", sa.Column("is_bundle", sa.Boolean(), server_default=sa.text("false"), nullable=False)
)
mc = sa.table(
"menu_categories",
sa.column("id", sa.Uuid()),
sa.column("name", sa.Unicode(length=255)),
sa.column("is_active", sa.Boolean()),
sa.column("is_fixture", sa.Boolean()),
sa.column("sort_order", sa.Integer()),
)
op.execute(
mc.insert().values(
id="6752ed9d-6f1a-4941-940a-17759dcc6720",
name="Bundles",
is_active=True,
is_fixture=True,
sort_order=0,
)
)
def downgrade():
op.drop_table("bundle_items")
op.drop_column("stock_keeping_units", "is_bundle")
op.drop_constraint(op.f("fk_inventories_parent_id_inventories"), "inventories", type_="foreignkey")
op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique")
op.create_unique_constraint(
op.f("uq_inventories_kot_id"),
"inventories",
["kot_id", "sku_id", "is_happy_hour", "price"],
postgresql_nulls_not_distinct=False,
)
op.drop_column("inventories", "parent_id")
op.drop_column("inventories", "type")
mc = sa.table(
"menu_categories",
sa.column("id", sa.Uuid()),
)
op.execute(mc.delete().where(mc.c.id == "6752ed9d-6f1a-4941-940a-17759dcc6720"))
+4
View File
@@ -1,6 +1,7 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from ..models.bill import Bill
from ..models.bundle_item import BundleItem
from ..models.customer import Customer
from ..models.customer_discount import CustomerDiscount
from ..models.db_setting import DbSetting
@@ -9,6 +10,7 @@ from ..models.food_table import FoodTable
from ..models.guest_book import GuestBook
from ..models.inventory import Inventory
from ..models.inventory_modifier import InventoryModifier
from ..models.inventory_type import InventoryType
from ..models.kot import Kot
from ..models.login_history import LoginHistory
from ..models.menu_category import MenuCategory
@@ -43,6 +45,7 @@ from .base_class import reg
__all__ = [
"Bill",
"BundleItem",
"Customer",
"CustomerDiscount",
"DbSetting",
@@ -51,6 +54,7 @@ __all__ = [
"GuestBook",
"Inventory",
"InventoryModifier",
"InventoryType",
"Kot",
"LoginHistory",
"MenuCategory",
+2
View File
@@ -8,6 +8,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .core.config import settings
from .db.base import reg # noqa: F401
from .routers import (
bundle,
customer,
customer_discount,
db_settings,
@@ -72,6 +73,7 @@ app.include_router(printer.router, prefix="/api/printers", tags=["printers"])
app.include_router(menu_category.router, prefix="/api/menu-categories", tags=["products"])
app.include_router(product.router, prefix="/api/products", tags=["products"])
app.include_router(bundle.router, prefix="/api/bundles", tags=["products"])
app.include_router(temporal_product.router, prefix="/api/temporal-products", tags=["products"])
app.include_router(device.router, prefix="/api/devices", tags=["devices"])
app.include_router(sale_category.router, prefix="/api/sale-categories", tags=["products"])
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import uuid
from datetime import date
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Numeric, Uuid, func, text
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db.base_class import reg
if TYPE_CHECKING:
from .stock_keeping_unit import StockKeepingUnit
@reg.mapped_as_dataclass(unsafe_hash=True)
class BundleItem:
__tablename__ = "bundle_items"
id: Mapped[uuid.UUID] = mapped_column(
Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()")
)
bundle_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False)
item_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False)
sale_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False)
valid_from: Mapped[date | None] = mapped_column(Date(), nullable=True)
valid_till: Mapped[date | None] = mapped_column(Date(), nullable=True)
bundle: Mapped[StockKeepingUnit] = relationship(
"StockKeepingUnit", back_populates="bundle_items", foreign_keys=[bundle_id]
)
item: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", foreign_keys=[item_id])
__table_args__ = (
postgresql.ExcludeConstraint(
(bundle_id, "="),
(item_id, "="),
(func.daterange(valid_from, valid_till, text("'[]'")), "&&"),
),
)
def __init__(
self,
quantity: Decimal,
sale_price: Decimal,
bundle_id: uuid.UUID,
item_id: uuid.UUID,
bundle: StockKeepingUnit | None = None,
item: StockKeepingUnit | None = None,
valid_from: date | None = None,
valid_till: date | None = None,
id_: uuid.UUID | None = None,
):
self.quantity = quantity
self.sale_price = sale_price
self.valid_from = valid_from
self.valid_till = valid_till
if bundle_id is not None:
self.bundle_id = bundle_id
if item_id is not None:
self.item_id = item_id
if bundle is not None:
self.bundle = bundle
if item is not None:
self.item = item
if id_ is not None:
self.id = id_
+12 -1
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
ColumnElement,
Enum,
ForeignKey,
Integer,
Numeric,
@@ -21,6 +22,8 @@ from sqlalchemy import (
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column, relationship
from barker.models.inventory_type import InventoryType
from ..db.base_class import reg
from .tax import Tax
@@ -34,7 +37,7 @@ if TYPE_CHECKING:
@reg.mapped_as_dataclass(unsafe_hash=True)
class Inventory:
__tablename__ = "inventories"
__table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price"),)
__table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()"))
kot_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("kots.id"), nullable=False, index=True)
@@ -46,10 +49,16 @@ class Inventory:
tax_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("taxes.id"), nullable=False)
discount: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
type_: Mapped[InventoryType] = mapped_column(
"type", Enum(InventoryType), server_default=text("regular"), nullable=False
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True)
kot: Mapped[Kot] = relationship(back_populates="inventories")
tax: Mapped[Tax] = relationship(back_populates="inventories")
sku: Mapped[StockKeepingUnit] = relationship(back_populates="inventories")
parent: Mapped[Inventory | None] = relationship("Inventory", remote_side="Inventory.id", back_populates="children")
children: Mapped[list[Inventory]] = relationship("Inventory", back_populates="parent")
modifiers: Mapped[list[InventoryModifier]] = relationship(back_populates="inventory")
@@ -64,6 +73,7 @@ class Inventory:
tax_id: uuid.UUID | None,
tax_rate: Decimal,
sort_order: int,
type_: InventoryType = InventoryType.regular,
sku: StockKeepingUnit | None = None,
tax: Tax | None = None,
):
@@ -78,6 +88,7 @@ class Inventory:
self.tax_id = tax_id
self.tax_rate = tax_rate
self.sort_order = sort_order
self.type_ = type_
if sku is not None:
self.sku = sku
if tax is not None:
+7
View File
@@ -0,0 +1,7 @@
import enum
class InventoryType(str, enum.Enum):
regular = "regular"
bundle = "bundle"
bundle_item = "bundle_item"
+9 -2
View File
@@ -57,20 +57,27 @@ class ProductVersion:
def __init__(
self,
product_id: uuid.UUID,
product_id: uuid.UUID | None,
name: str = "",
fraction_units: str = "",
sale_category_id: uuid.UUID | None = None,
valid_from: date | None = None,
valid_till: date | None = None,
product: Product | None = None,
sale_category: SaleCategory | None = None,
id_: uuid.UUID | None = None,
):
self.product_id = product_id
if product_id is not None:
self.product_id = product_id
self.name = name
self.fraction_units = fraction_units
if product is not None:
self.product = product
if sale_category_id is not None:
self.sale_category_id = sale_category_id
self.valid_from = valid_from
self.valid_till = valid_till
if sale_category is not None:
self.sale_category = sale_category
if id_ is not None:
self.id = id_
@@ -11,6 +11,7 @@ from ..db.base_class import reg
if TYPE_CHECKING:
from .bundle_item import BundleItem
from .inventory import Inventory
from .product import Product
from .sku_version import SkuVersion
@@ -25,21 +26,27 @@ class StockKeepingUnit:
)
product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False)
is_not_available: Mapped[bool] = mapped_column(Boolean, nullable=False)
is_bundle: Mapped[bool] = mapped_column(Boolean, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
product: Mapped[Product] = relationship("Product", back_populates="skus")
inventories: Mapped[list[Inventory]] = relationship(back_populates="sku")
versions: Mapped[list[SkuVersion]] = relationship(back_populates="sku")
bundle_items: Mapped[list[BundleItem]] = relationship(
"BundleItem", back_populates="bundle", foreign_keys="BundleItem.bundle_id"
)
def __init__(
self,
is_not_available: bool = False,
is_bundle: bool = False,
sort_order: int = 0,
product_id: uuid.UUID | None = None,
product: Product | None = None,
id_: uuid.UUID | None = None,
) -> None:
self.is_not_available = is_not_available
self.is_bundle = is_bundle
self.sort_order = sort_order
if product_id is not None:
self.product_id = product_id
+43
View File
@@ -1,5 +1,12 @@
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import and_, or_
from barker.models.product import Product
from barker.models.product_version import ProductVersion
from barker.models.sku_version import SkuVersion
from barker.models.stock_keeping_unit import StockKeepingUnit
from ..core.config import settings
@@ -32,3 +39,39 @@ def dates_overlap(start1: date | None, end1: date | None, start2: date | None, e
if end2 is None:
end2 = date.max
return start1 <= end2 and start2 <= end1
def _pv_active(date_: date):
return and_(
or_(ProductVersion.valid_from == None, ProductVersion.valid_from <= date_), # noqa: E711
or_(ProductVersion.valid_till == None, ProductVersion.valid_till >= date_), # noqa: E711
)
def _sv_active(date_: date):
return and_(
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
def _pv_onclause(date_: date):
return and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
)
def _sv_onclause(date_: date):
return and_(
SkuVersion.sku_id == StockKeepingUnit.id,
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
+520
View File
@@ -0,0 +1,520 @@
import uuid
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Security, status
from sqlalchemy import Date, func, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import contains_eager
from ..core.config import settings
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..models.bundle_item import BundleItem as BundleItemModel
from ..models.inventory import Inventory
from ..models.kot import Kot
from ..models.product import Product
from ..models.product_version import ProductVersion
from ..models.sku_version import SkuVersion
from ..models.stock_keeping_unit import StockKeepingUnit
from ..models.voucher import Voucher
from ..schemas import bundle as schemas
from ..schemas.menu_category import MenuCategoryLink
from ..schemas.user_token import UserToken
from . import _pv_onclause, _sv_onclause, effective_date
router = APIRouter()
FRACTION_1 = Decimal("1")
YIELD_1 = Decimal("1")
COST_0 = Decimal("0")
# ---------- helpers ----------
def bundle_blank() -> schemas.BundleBlank:
return schemas.BundleBlank(
name="",
units="",
sale_price=Decimal("0.00"),
has_happy_hour=False,
is_not_available=False,
sort_order=0,
items=[],
)
def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemModel]) -> schemas.Bundle:
sku = sv.sku
return schemas.Bundle(
id_=sku.id,
version_id=sv.id,
name=pv.name,
units=pv.fraction_units,
sale_price=sv.sale_price,
has_happy_hour=sv.has_happy_hour,
is_not_available=sku.is_not_available,
sort_order=sku.sort_order,
menu_category=MenuCategoryLink(
id_=sv.menu_category_id,
name=sv.menu_category.name,
skus=[],
)
if sv.menu_category is not None
else None,
items=[
schemas.BundleItem(
id_=bi.id,
name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})",
item_id=bi.item_id,
sale_price=bi.sale_price,
quantity=bi.quantity,
)
for bi in items
],
)
# ---------- routes ----------
@router.post("", response_model=None)
def save(
data: schemas.BundleIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> None:
try:
with SessionFuture() as db:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2)
sale_category_id = db.execute(
select(ProductVersion.sale_category_id)
.join(Product, onclause=_pv_onclause(date_))
.join(Product.skus)
.where(StockKeepingUnit.id == data.items[0].item_id)
).scalar_one()
product = Product(sort_order=data.sort_order)
db.add(product)
db.flush()
pv = ProductVersion(
product_id=None,
product=product,
name=data.name,
fraction_units=data.units, # per your rule
sale_category_id=sale_category_id,
sale_category=None,
valid_from=date_,
valid_till=None,
)
db.add(pv)
sku = StockKeepingUnit(
product=product,
is_not_available=data.is_not_available,
is_bundle=True,
sort_order=data.sort_order,
)
db.add(sku)
db.flush()
sv = SkuVersion(
sku_id=sku.id,
units=data.units,
fraction=FRACTION_1,
product_yield=YIELD_1,
cost_price=COST_0,
sale_price=sale_price,
has_happy_hour=data.has_happy_hour,
menu_category_id=data.menu_category.id_,
valid_from=date_,
valid_till=None,
)
db.add(sv)
# bundle items
for it in data.items:
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
quantity=round(it.quantity, 5),
sale_price=round(it.sale_price, 2),
valid_from=date_,
valid_till=None,
)
)
db.commit()
except SQLAlchemyError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.put("/{id_}", response_model=None)
def update_route(
id_: uuid.UUID, # bundle header SKU id
data: schemas.BundleIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> None:
try:
with SessionFuture() as db:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2)
# Load header SKU + active header SkuVersion + menu category + product + active ProductVersion
sv: SkuVersion | None = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.join(SkuVersion.menu_category)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(
contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
contains_eager(SkuVersion.menu_category),
)
)
.unique()
.scalars()
.one_or_none()
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
sku = sv.sku
product = sku.product
pv = product.versions[0]
pv_changed = pv.name != data.name
sku.is_not_available = data.is_not_available
sku.sort_order = data.sort_order
product.sort_order = data.sort_order
if pv_changed:
if pv.valid_from == date_:
pv.name = data.name
else:
pv.valid_till = date_ - timedelta(days=1)
db.add(
ProductVersion(
product_id=product.id,
product=product,
name=data.name,
fraction_units=pv.fraction_units,
sale_category_id=pv.sale_category_id,
valid_from=date_,
valid_till=None,
)
)
sv_changed = (
sv.units != data.units
or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price
or sv.has_happy_hour != data.has_happy_hour
or sv.menu_category_id != data.menu_category.id_
)
if sv_changed:
if sv.valid_from == date_:
sv.units = data.units
sv.sale_price = sale_price
sv.has_happy_hour = data.has_happy_hour
sv.menu_category_id = data.menu_category.id_
# # enforce bundle constants
# sv.fraction = FRACTION_1
# sv.product_yield = YIELD_1
# sv.cost_price = COST_0
else:
sv.valid_till = date_ - timedelta(days=1)
db.add(
SkuVersion(
sku_id=sku.id,
units=data.units,
fraction=FRACTION_1,
product_yield=YIELD_1,
cost_price=COST_0,
sale_price=sale_price,
has_happy_hour=data.has_happy_hour,
menu_category_id=data.menu_category.id_,
valid_from=date_,
valid_till=None,
)
)
# ---- Bundle items replace/update
existing = db.execute(select(BundleItemModel).where(BundleItemModel.bundle_id == sku.id)).scalars().all()
existing_by_item = {x.item_id: x for x in existing}
incoming_ids = {x.item_id for x in data.items}
existing_by_item = {x.item_id: x for x in existing}
incoming_ids = {x.item_id for x in data.items}
# delete removed
for ex_d in existing:
if ex_d.item_id not in incoming_ids:
if ex_d.valid_from == date_:
db.delete(ex_d)
else:
ex_d.valid_till = date_ - timedelta(days=1)
# add/update
for it in data.items:
ex = existing_by_item.get(it.item_id)
if ex is None:
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
sale_price=round(it.sale_price, 2),
quantity=round(it.quantity, 5),
valid_from=date_,
valid_till=None,
)
)
elif ex.valid_from == date_:
ex.sale_price = round(it.sale_price, 2)
ex.quantity = round(it.quantity, 5)
else:
ex.valid_till = date_ - timedelta(days=1)
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
sale_price=round(it.sale_price, 2),
quantity=round(it.quantity, 5),
valid_from=date_,
valid_till=None,
)
)
db.commit()
except SQLAlchemyError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("", response_model=schemas.BundleBlank)
def show_blank(
user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.BundleBlank:
return bundle_blank()
@router.get("/list", response_model=list[schemas.Bundle])
def show_list(
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> list[schemas.Bundle]:
with SessionFuture() as db:
sv_onclause = _sv_onclause(date_)
pv_onclause = _pv_onclause(date_)
rows = (
db.execute(
select(SkuVersion)
# .select_from(SkuVersion)
.join(StockKeepingUnit, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.join(SkuVersion.menu_category)
.where(StockKeepingUnit.is_bundle == True) # noqa: E712
.options(
contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product),
contains_eager(SkuVersion.menu_category),
)
.order_by(StockKeepingUnit.sort_order, SkuVersion.units)
)
.unique()
.scalars()
.all()
)
out: list[schemas.Bundle] = []
for sv in rows:
pv = sv.sku.product.versions[0]
items = (
db.execute(
select(BundleItemModel)
.join(BundleItemModel.item)
.join(SkuVersion, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.where(
BundleItemModel.bundle_id == sv.sku_id,
or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711
or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711
)
.options(
contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions),
contains_eager(BundleItemModel.item)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
)
)
.unique()
.scalars()
.all()
)
out.append(_bundle_info(pv=pv, sv=sv, items=items))
return out
@router.get("/{id_}", response_model=schemas.Bundle)
def show_id(
id_: uuid.UUID, # bundle header SKU id
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.Bundle:
with SessionFuture() as db:
sv_onclause = _sv_onclause(date_)
pv_onclause = _pv_onclause(date_)
sv = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.join(SkuVersion.menu_category)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(
contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product),
contains_eager(SkuVersion.menu_category),
)
)
.unique()
.scalar_one_or_none()
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
pv = sv.sku.product.versions[0]
items = (
db.execute(
select(BundleItemModel)
.join(BundleItemModel.item)
.join(SkuVersion, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.where(
BundleItemModel.bundle_id == sv.sku_id,
or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711
or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711
)
.options(
contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions),
contains_eager(BundleItemModel.item)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
)
)
.unique()
.scalars()
.all()
)
return _bundle_info(pv=pv, sv=sv, items=items)
@router.delete("/{id_}", response_model=None)
def delete_route(
id_: uuid.UUID, # bundle header SKU id
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> None:
with SessionFuture() as db:
sv_onclause = _sv_onclause(date_)
pv_onclause = _pv_onclause(date_)
day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day")
billed = db.execute(
select(func.count(Inventory.id))
.join(Inventory.kot)
.join(Kot.voucher)
.where(Inventory.sku_id == id_, day >= date_)
).scalar_one()
if billed > 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="The cannot delete this product as it was billed",
)
sv = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=sv_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=pv_onclause)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product))
)
.unique()
.scalar_one_or_none()
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
pv = sv.sku.product.versions[0]
# close/delete sku version
if sv.valid_from == date_:
db.delete(sv)
else:
sv.valid_till = date_ - timedelta(days=1)
# close/delete product version
if pv.valid_from == date_:
db.delete(pv)
else:
pv.valid_till = date_ - timedelta(days=1)
items = (
db.execute(
select(BundleItemModel).where(
BundleItemModel.bundle_id == id_,
or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711
or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711
)
)
.scalars()
.all()
)
for bi in items:
if bi.valid_from == date_:
db.delete(bi)
else:
bi.valid_till = date_ - timedelta(days=1)
db.commit()
+23 -69
View File
@@ -29,7 +29,7 @@ from ..schemas.product_query import ProductQuery
from ..schemas.sale_category import SaleCategoryLink
from ..schemas.tax import TaxLink
from ..schemas.user_token import UserToken
from . import effective_date
from . import _pv_onclause, _sv_onclause, effective_date
router = APIRouter()
@@ -168,22 +168,8 @@ def update_route(
) -> None:
try:
with SessionFuture() as db:
product_version_onclause = and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
)
sku_version_onclause = and_(
SkuVersion.sku_id == StockKeepingUnit.id,
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
product_version_onclause = _pv_onclause(date_)
sku_version_onclause = _sv_onclause(date_)
version: ProductVersion = db.execute(
select(ProductVersion)
.join(Product, onclause=product_version_onclause)
@@ -446,46 +432,35 @@ def product_list(date_: date, db: Session) -> list[schemas.Product]:
# ]
@router.get("/query")
@router.get("/query", response_model=list[ProductQuery])
def show_term(
q: str | None = None,
mc: uuid.UUID | None = None,
sc: uuid.UUID | None = None,
date_: date = Depends(effective_date),
current_user: UserToken = Depends(get_user),
):
product_version_onclause = and_(
ProductVersion.product_id == Product.id,
or_(
ProductVersion.valid_from == None, # noqa: E711
ProductVersion.valid_from <= date_,
),
or_(
ProductVersion.valid_till == None, # noqa: E711
ProductVersion.valid_till >= date_,
),
)
sku_version_onclause = and_(
SkuVersion.sku_id == StockKeepingUnit.id,
or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711
or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711
)
) -> list[ProductQuery]:
product_version_onclause = _pv_onclause(date_)
sku_version_onclause = _sv_onclause(date_)
print(f"Fetching products for MenuCategory: {mc}, SaleCategory: {sc}, Date: {date_}")
list_: list[ProductQuery] = []
query = (
select(SkuVersion)
.join(SkuVersion.menu_category)
.join(StockKeepingUnit, onclause=sku_version_onclause)
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
# .join(SkuVersion.menu_category)
# .join(ProductVersion.sale_category)
.join(ProductVersion.sale_category)
.join(SaleCategory.tax)
.options(
contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions)
contains_eager(SkuVersion.menu_category),
contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions)
.contains_eager(ProductVersion.sale_category)
.contains_eager(SaleCategory.tax),
)
)
if mc is not None:
query = query.join(SkuVersion.menu_category)
if sc is not None:
query = query.join(SkuVersion.menu_category).join(ProductVersion.sale_category).join(SaleCategory.tax)
if mc is not None:
query = query.where(SkuVersion.menu_category_id == mc).order_by(
Product.sort_order, ProductVersion.name, StockKeepingUnit.sort_order, SkuVersion.units
@@ -498,36 +473,15 @@ def show_term(
StockKeepingUnit.sort_order,
SkuVersion.units,
)
if mc is not None:
query = query.options(
contains_eager(SkuVersion.menu_category),
)
if sc is not None:
query = query.options(
contains_eager(ProductVersion.sale_category).contains_eager(SaleCategory.tax),
)
if q is not None:
for name in q.split():
query = query.where(or_(ProductVersion.name.ilike(f"%{name}%"), SkuVersion.units.ilike(f"%{name}%")))
with SessionFuture() as db:
for item in db.execute(query).unique().scalars().all():
if sc is not None:
list_.append(
ProductQuery(
id_=item.sku_id,
name=f"{item.sku.product.versions[0].name} ({item.units})",
menu_category=MenuCategoryLink(
id_=item.menu_category_id, name=item.menu_category.name, skus=[]
),
price=item.sale_price,
has_happy_hour=item.has_happy_hour,
is_not_available=item.sku.is_not_available,
)
)
if mc is not None:
list_.append(query_product_info(item, False))
if item.has_happy_hour:
list_.append(query_product_info(item, True))
list_.append(query_product_info(item, False))
if mc is not None and item.has_happy_hour:
list_.append(query_product_info(item, True))
return list_
+55
View File
@@ -0,0 +1,55 @@
import uuid
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from . import Daf, to_camel
from .menu_category import MenuCategoryLink
class BundleItem(BaseModel):
id_: uuid.UUID | None = None
name: str = Field(..., min_length=1)
item_id: uuid.UUID = Field(..., description="StockKeepingUnit ID of the item")
sale_price: Daf = Field(ge=Decimal(0))
quantity: Daf = Field(gt=Decimal(0))
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class BundleIn(BaseModel):
name: str = Field(..., min_length=1)
units: str = Field(..., min_length=1)
sale_price: Daf = Field(ge=Decimal(0))
has_happy_hour: bool
is_not_available: bool
sort_order: int
menu_category: MenuCategoryLink = Field(...)
items: list[BundleItem]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class Bundle(BundleIn):
id_: uuid.UUID # Sku ID
version_id: uuid.UUID # SkuVersion ID
items: list[BundleItem]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class BundleBlank(BaseModel):
name: str
units: str
sale_price: Daf
has_happy_hour: bool
is_not_available: bool
sort_order: int
menu_category: MenuCategoryLink | None = None
items: list[BundleItem]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
+4
View File
@@ -13,6 +13,10 @@ export const routes: Routes = [
path: 'bill-settlement-report',
loadChildren: () => import('./bill-settlement-report/bill-settlement-report.routes').then((mod) => mod.routes),
},
{
path: 'bundles',
loadChildren: () => import('./bundles/bundles.routes').then((mod) => mod.routes),
},
{
path: 'cashier-report',
loadChildren: () => import('./cashier-report/cashier-report.routes').then((mod) => mod.routes),
@@ -0,0 +1,16 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { BundleItem } from '../bundle';
export class BundleDetailDatasource extends DataSource<BundleItem> {
constructor(private data: Observable<BundleItem[]>) {
super();
}
connect(): Observable<BundleItem[]> {
return this.data;
}
disconnect() {}
}
Whitespace-only changes.
@@ -0,0 +1,32 @@
<h1 mat-dialog-title>Edit Bundle Item</h1>
<div mat-dialog-content>
<form [formGroup]="form">
<mat-form-field class="w-full">
<mat-label>Item</mat-label>
<input matInput type="text" formControlName="product" [matAutocomplete]="autoP" />
<mat-autocomplete #autoP="matAutocomplete" [displayWith]="displayFn" (optionSelected)="productSelected($event)">
@for (p of products$ | async; track p) {
<mat-option [value]="p">{{ p.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<div class="flex flex-row flex-wrap gap-3">
<mat-form-field class="flex-auto">
<mat-label>Quantity</mat-label>
<input matInput type="number" formControlName="quantity" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" />
</mat-form-field>
</div>
</form>
</div>
<div mat-dialog-actions>
<button mat-raised-button color="warn" [mat-dialog-close]="false">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
</div>
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BundleDetailDialogComponent } from './bundle-detail-dialog.component';
describe('BundleDetailDialogComponent', () => {
let component: BundleDetailDialogComponent;
let fixture: ComponentFixture<BundleDetailDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BundleDetailDialogComponent],
}).compileComponents();
fixture = TestBed.createComponent(BundleDetailDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,110 @@
import { AsyncPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { debounceTime, distinctUntilChanged, Observable, of as observableOf, switchMap } from 'rxjs';
import { ProductQuery } from '../../core/product-query';
import { ProductService } from '../../product/product.service';
import { BundleItem } from '../bundle';
@Component({
selector: 'app-bundle-detail-dialog',
templateUrl: './bundle-detail-dialog.component.html',
styleUrls: ['./bundle-detail-dialog.component.css'],
standalone: true,
imports: [
AsyncPipe,
MatAutocompleteModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
],
})
export class BundleDetailDialogComponent implements OnInit {
private readonly dialogRef = inject<MatDialogRef<BundleDetailDialogComponent>>(MatDialogRef);
private readonly productSer = inject(ProductService);
data = inject<{ item: BundleItem }>(MAT_DIALOG_DATA);
selectedProduct: ProductQuery | null = null;
products$: Observable<ProductQuery[]>;
form: FormGroup<{
product: FormControl<ProductQuery | string>;
quantity: FormControl<number>;
salePrice: FormControl<number>;
}>;
constructor() {
this.form = new FormGroup({
product: new FormControl<ProductQuery | string>('', { nonNullable: true }),
quantity: new FormControl<number>(1, { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
});
this.products$ = this.form.controls.product.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => {
if (typeof x !== 'string') return observableOf([]);
return x.trim() === '' ? observableOf([]) : this.productSer.query(x);
}),
);
// typing after selecting invalidates prior selection
this.form.controls.product.valueChanges.subscribe((x) => {
if (typeof x === 'string') this.selectedProduct = null;
});
}
ngOnInit(): void {
// preload display text using existing name
this.form.setValue({
product: this.data.item.name ?? '',
quantity: Number(this.data.item.quantity ?? 1),
salePrice: Number(this.data.item.salePrice ?? 0),
});
this.selectedProduct = null;
}
displayFn(product?: ProductQuery | string): string {
return !product ? '' : typeof product === 'string' ? product : product.name;
}
productSelected(event: MatAutocompleteSelectedEvent): void {
const p = event.option.value as ProductQuery;
this.selectedProduct = p;
}
accept(): void {
const v = this.form.value;
const quantity = Number(v.quantity ?? 0);
if (Number.isNaN(quantity) || quantity <= 0) return;
const salePrice = Number(v.salePrice ?? 0);
if (Number.isNaN(salePrice) || salePrice < 0) return;
// If a new product was selected, update itemId + name
if (this.selectedProduct?.id) {
this.data.item.itemId = this.selectedProduct.id;
this.data.item.name = this.selectedProduct.name ?? '';
} else {
// If no selection, don't allow changing SKU silently
// but still allow quantity/price edit
}
this.data.item.quantity = quantity;
this.data.item.salePrice = salePrice;
this.dialogRef.close(this.data.item);
}
}
@@ -0,0 +1,11 @@
.nutrition-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
align-items: stretch;
justify-items: stretch;
}
.nutrition-grid > * {
width: 100%;
box-sizing: border-box; /* helps with padding */
}
@@ -0,0 +1,120 @@
<h2>Bundle</h2>
<form [formGroup]="form">
<div class="row-container">
<mat-form-field class="basis-3-4">
<mat-label>Name</mat-label>
<input matInput #name formControlName="name" />
</mat-form-field>
<mat-form-field class="basis-1-4">
<mat-label>Units</mat-label>
<input matInput formControlName="units" />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="basis-1-4">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" readonly="" />
<mat-hint>Computed from bundle items</mat-hint>
</mat-form-field>
<mat-form-field class="basis-2-4">
<mat-label>Menu Category</mat-label>
<mat-select formControlName="menuCategory">
@for (mc of menuCategories; track mc) {
<mat-option [value]="mc.id">
{{ mc.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="hasHappyHour" class="basis-1-4">Has Happy Hour?</mat-checkbox>
<mat-checkbox formControlName="isNotAvailable" class="basis-1-4">Not Available?</mat-checkbox>
</div>
<h2>Items</h2>
<div formGroupName="addRow" class="nutrition-grid">
<mat-form-field class="flex-auto">
<mat-label>Product</mat-label>
<input
type="text"
matInput
formControlName="itemId"
[matAutocomplete]="autoItem"
[value]="displayFn(form.controls.addRow.controls.itemId.value)"
/>
<mat-autocomplete #autoItem="matAutocomplete" [displayWith]="displayFn" (optionSelected)="bundleSelected($event)">
@for (p of itemProducts | async; track p) {
<mat-option [value]="p">{{ p.name }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Quantity</mat-label>
<input matInput type="number" formControlName="quantity" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="addRow()" class="flex-auto">Add</button>
</div>
</form>
<div class="row-container wrapped">
<mat-table [dataSource]="dataSource" aria-label="Bundle Items" class="flex-auto">
<!-- Name Column (name is "name (units)") -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Item</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.name }}
</mat-cell>
</ng-container>
<!-- Quantity Column -->
<ng-container matColumnDef="quantity">
<mat-header-cell *matHeaderCellDef class="right">Qty</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">
{{ row.quantity }}
</mat-cell>
</ng-container>
<!-- Sale Price Column -->
<ng-container matColumnDef="salePrice">
<mat-header-cell *matHeaderCellDef class="right">Sale Price</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">
{{ row.salePrice | currency: 'INR' }}
</mat-cell>
</ng-container>
<!-- Action Column -->
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell>
<mat-cell *matCellDef="let row" class="center">
<button mat-icon-button tabindex="-1" (click)="editRow(row)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button tabindex="-1" color="warn" (click)="deleteRow(row)">
<mat-icon>delete</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
</div>
<div class="row-container">
<button mat-raised-button color="primary" (click)="save()">Save</button>
@if (!!item.id) {
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
}
</div>
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { BundleDetailComponent } from './bundle-detail.component';
describe('BundleDetailComponent', () => {
let component: BundleDetailComponent;
let fixture: ComponentFixture<BundleDetailComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [BundleDetailComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BundleDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,325 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatOptionModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged, Observable, switchMap } from 'rxjs';
import { MenuCategory } from '../../core/menu-category';
import { ProductQuery } from '../../core/product-query';
import { ProductService } from '../../product/product.service';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { Bundle, BundleItem } from '../bundle';
import { BundleService } from '../bundle.service';
import { BundleDetailDatasource } from './bundle-detail-datasource';
import { BundleDetailDialogComponent } from './bundle-detail-dialog.component';
@Component({
selector: 'app-bundle-detail',
templateUrl: './bundle-detail.component.html',
styleUrls: ['./bundle-detail.component.css'],
imports: [
AsyncPipe,
CurrencyPipe,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
],
})
export class BundleDetailComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private ser = inject(BundleService);
private productSer = inject(ProductService);
@ViewChild('name', { static: true }) nameElement?: ElementRef;
form: FormGroup<{
// Bundle header fields
name: FormControl<string>;
units: FormControl<string>;
salePrice: FormControl<number>;
menuCategory: FormControl<string>;
hasHappyHour: FormControl<boolean>;
isNotAvailable: FormControl<boolean>;
// Item add row
addRow: FormGroup<{
itemId: FormControl<ProductQuery | string>;
quantity: FormControl<number>;
salePrice: FormControl<number>;
}>;
}>;
menuCategories: MenuCategory[] = [];
public items$ = new BehaviorSubject<BundleItem[]>([]);
dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$);
item: Bundle = new Bundle();
itemProduct: ProductQuery | null = null;
itemProducts: Observable<ProductQuery[]>;
displayedColumns = ['name', 'quantity', 'salePrice', 'action'];
constructor() {
this.form = new FormGroup({
name: new FormControl<string>('', { nonNullable: true }),
units: new FormControl<string>('', { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
menuCategory: new FormControl<string>('', { nonNullable: true }),
hasHappyHour: new FormControl<boolean>(false, { nonNullable: true }),
isNotAvailable: new FormControl<boolean>(false, { nonNullable: true }),
addRow: new FormGroup({
itemId: new FormControl<ProductQuery | string>('', { nonNullable: true }),
quantity: new FormControl<number>(1, { nonNullable: true }),
salePrice: new FormControl<number>(0, { nonNullable: true }),
}),
});
this.itemProducts = this.form.controls.addRow.controls.itemId.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => {
// if user types, x is string; if user selects, x is Product
if (typeof x !== 'string') {
return observableOf([]);
}
return x.trim() === '' ? observableOf([]) : this.productSer.query(x);
}),
);
this.form.controls.addRow.controls.itemId.valueChanges.subscribe((x) => {
if (typeof x === 'string') {
this.itemProduct = null;
}
});
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as {
item: Bundle;
menuCategories: MenuCategory[];
};
this.menuCategories = data.menuCategories;
this.showItem(data.item);
});
}
displayFn(product?: ProductQuery | string): string {
return !product ? '' : typeof product === 'string' ? product : product.name;
}
bundleSelected(event: MatAutocompleteSelectedEvent): void {
const product = event.option.value as ProductQuery;
this.itemProduct = product;
this.form.controls.addRow.controls.salePrice.setValue(product.price ?? 0);
}
private recalcBundleSalePrice(): void {
const total = (this.item.items ?? []).reduce((sum, x) => {
const qty = Number(x.quantity ?? 0);
const price = Number(x.salePrice ?? 0);
if (Number.isNaN(qty) || Number.isNaN(price)) return sum;
return sum + qty * price;
}, 0);
// keep model + form in sync
this.item.salePrice = Number(total.toFixed(2));
// if the control is disabled, use patchValue
this.form.controls.salePrice.patchValue(this.item.salePrice);
}
showItem(item: Bundle) {
this.item = item;
this.form.setValue({
name: this.item.name ?? '',
units: this.item.units ?? '',
salePrice: Number(this.item.salePrice ?? 0),
menuCategory: this.item.menuCategory?.id ?? '',
hasHappyHour: this.item.hasHappyHour ?? false,
isNotAvailable: this.item.isNotAvailable ?? false,
addRow: {
itemId: '',
quantity: 1,
salePrice: 0,
},
});
this.itemProduct = null;
this.form.controls.salePrice.disable({ emitEvent: false });
this.items$.next(this.item.items ?? []);
this.recalcBundleSalePrice();
}
ngAfterViewInit() {
setTimeout(() => {
if (this.nameElement !== undefined) {
this.nameElement.nativeElement.focus();
}
}, 0);
}
resetAddRow() {
this.form.controls.addRow.reset();
this.itemProduct = null;
}
addRow() {
const v = this.form.value.addRow;
if (!v) {
return;
}
if (!this.itemProduct?.id) {
this.snackBar.open('Please select a product', 'Error');
return;
}
const quantity = Number(v.quantity ?? 0);
if (Number.isNaN(quantity) || quantity <= 0) {
this.snackBar.open('Quantity has to be > 0', 'Error');
return;
}
const salePrice = Number(v.salePrice ?? 0);
if (Number.isNaN(salePrice) || salePrice < 0) {
this.snackBar.open('Sale Price has to be >= 0', 'Error');
return;
}
// name is expected from backend as "name (units)" on GET;
// for new rows we keep it blank (or you can set placeholder)
const bi = new BundleItem({
itemId: this.itemProduct?.id,
name: this.itemProduct?.name ?? '',
quantity,
salePrice,
});
this.item.items.push(bi);
this.items$.next(this.item.items);
this.resetAddRow();
this.recalcBundleSalePrice();
}
editRow(row: BundleItem) {
const dialogRef = this.dialog.open(BundleDetailDialogComponent, {
width: '650px',
data: {
item: JSON.parse(JSON.stringify(row)) as BundleItem,
},
});
dialogRef.afterClosed().subscribe((result: boolean | BundleItem) => {
if (!result) {
return;
}
Object.assign(row, result as BundleItem);
this.items$.next(this.item.items);
this.resetAddRow();
this.recalcBundleSalePrice();
});
}
deleteRow(row: BundleItem) {
this.item.items.splice(this.item.items.indexOf(row), 1);
this.items$.next(this.item.items);
this.recalcBundleSalePrice();
}
save() {
if (!this.item.items || this.item.items.length === 0) {
this.snackBar.open('Bundle must contain at least one item', 'Error');
return;
}
this.ser.saveOrUpdate(this.getItem()).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/bundles');
},
error: (error) => {
this.snackBar.open(error, 'Error');
},
});
}
delete() {
this.ser.delete(this.item.id as string).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/bundles');
},
error: (error) => {
this.snackBar.open(error, 'Error');
},
});
}
confirmDelete(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '250px',
data: { title: 'Delete Bundle?', content: 'Are you sure? This cannot be undone.' },
});
dialogRef.afterClosed().subscribe((result: boolean) => {
if (result) {
this.delete();
}
});
}
getItem(): Bundle {
const v = this.form.value;
this.item.name = v.name ?? '';
this.item.units = v.units ?? '';
this.item.salePrice = Number(v.salePrice ?? 0);
this.item.hasHappyHour = v.hasHappyHour ?? false;
this.item.isNotAvailable = v.isNotAvailable ?? false;
this.item.sortOrder = this.item.sortOrder ?? 0;
// menu category
const menuCategoryId = v.menuCategory ?? '';
if (!menuCategoryId) {
// keep it as-is; backend will 422 anyway, but we can show UI error too
this.snackBar.open('Menu Category is required', 'Error');
return this.item;
}
if (this.item.menuCategory === null || this.item.menuCategory === undefined) {
this.item.menuCategory = new MenuCategory();
}
this.item.menuCategory.id = menuCategoryId;
// ensure items array exists
if (!this.item.items) {
this.item.items = [];
}
return this.item;
}
}
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { BundleListResolver } from './bundle-list-resolver.service';
describe('BundleListResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BundleListResolver],
});
});
it('should be created', inject([BundleListResolver], (service: BundleListResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Bundle } from './bundle';
import { BundleService } from './bundle.service';
export const bundleListResolver: ResolveFn<Bundle[]> = () => {
return inject(BundleService).list();
};
@@ -0,0 +1,67 @@
import { DataSource } from '@angular/cdk/collections';
import { merge, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { Bundle } from '../bundle';
export class BundleListDataSource extends DataSource<Bundle> {
public data: Bundle[];
public filteredData: Bundle[];
public search: string;
public menuCategory: string;
constructor(
private readonly searchFilter: Observable<string>,
private readonly menuCategoryFilter: Observable<string>,
private readonly dataObs: Observable<Bundle[]>,
) {
super();
this.data = [];
this.filteredData = [];
this.search = '';
this.menuCategory = '';
}
connect(): Observable<Bundle[]> {
const dataMutations = [
this.dataObs.pipe(
tap((x) => {
this.data = x;
}),
),
this.searchFilter.pipe(
tap((x) => {
this.search = x;
}),
),
this.menuCategoryFilter.pipe(
tap((x) => {
this.menuCategory = x;
}),
),
];
return merge(...dataMutations).pipe(
map(() => this.getFilteredData(this.data, this.search, this.menuCategory)),
tap((x) => {
this.filteredData = x;
}),
);
}
disconnect() {}
private getFilteredData(data: Bundle[], search: string, menuCategory: string): Bundle[] {
const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean);
return data.filter((b: Bundle) => {
const mcName = b.menuCategory?.name ?? '';
const hay = `${b.name ?? ''} ${b.units ?? ''} ${mcName}`.toLowerCase();
const matchesSearch = tokens.length === 0 || tokens.every((t) => hay.includes(t));
const matchesMenuCategory = menuCategory === '' || (b.menuCategory?.id ?? '') === menuCategory;
return matchesSearch && matchesMenuCategory;
});
}
}
@@ -0,0 +1,35 @@
.right {
display: flex;
justify-content: flex-end;
}
.material-icons {
vertical-align: middle;
}
.mat-column-name {
margin-right: 4px;
}
.mat-column-price,
.mat-column-menuCategory,
.mat-column-saleCategory,
.mat-column-info {
margin-left: 4px;
margin-right: 4px;
}
.mat-column-quantity {
margin-left: 4px;
}
.items-list {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.item-name {
white-space: nowrap;
}
@@ -0,0 +1,94 @@
<h2 class="row-container space-between">
<span>Bundles</span>
<button mat-icon-button (click)="exportCsv()" [disabled]="!(data | async)?.length">
<mat-icon>save_alt</mat-icon>
</button>
<a mat-button [routerLink]="['/bundles', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
</h2>
<form [formGroup]="form" class="flex-col">
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Filter</mat-label>
<input type="text" matInput formControlName="filter" autocomplete="off" />
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Menu Category</mat-label>
<mat-select formControlName="menuCategory" (selectionChange)="filterOn($event.value)">
<mat-option>-- All Bundles --</mat-option>
@for (mc of menuCategories; track mc) {
<mat-option [value]="mc.id">
{{ mc.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</form>
<mat-table #table [dataSource]="dataSource" aria-label="Bundles">
<!-- Name -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
<mat-cell *matCellDef="let row">
<a [routerLink]="['/bundles', row.id]">{{ row.name }} ({{ row.units }})</a>
</mat-cell>
</ng-container>
<!-- Price -->
<ng-container matColumnDef="price">
<mat-header-cell *matHeaderCellDef class="right">Price</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">
{{ row.salePrice | currency: 'INR' }}
</mat-cell>
</ng-container>
<!-- Menu Category -->
<ng-container matColumnDef="menuCategory">
<mat-header-cell *matHeaderCellDef>Menu Category</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.menuCategory?.name }}
</mat-cell>
</ng-container>
<!-- Items -->
<ng-container matColumnDef="items">
<mat-header-cell *matHeaderCellDef>Items</mat-header-cell>
<mat-cell *matCellDef="let row">
<div class="items-list">
@for (item of row.items; track item) {
<div class="item-name">{{ item.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}</div>
}
</div>
</mat-cell>
</ng-container>
<!-- Info -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef>Details</mat-header-cell>
<mat-cell *matCellDef="let row">
<div class="row-container">
<div>
<mat-icon>
{{ row.hasHappyHour ? 'sentiment_satisfied_alt' : 'sentiment_dissatisfied' }}
</mat-icon>
<b>{{ row.hasHappyHour ? 'Happy Hour' : 'Regular' }}</b>
</div>
<div>
<mat-icon>
{{ row.isNotAvailable ? 'pause' : 'play_arrow' }}
</mat-icon>
<b>{{ row.isNotAvailable ? 'Not Available' : 'Available' }}</b>
</div>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
@@ -0,0 +1,22 @@
import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
import { BundleListComponent } from './bundle-list.component';
describe('BundleListComponent', () => {
let component: BundleListComponent;
let fixture: ComponentFixture<BundleListComponent>;
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [BundleListComponent],
}).compileComponents();
fixture = TestBed.createComponent(BundleListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should compile', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,117 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { MenuCategory } from '../../core/menu-category';
import { ToCsvService } from '../../shared/to-csv.service';
import { Bundle } from '../bundle';
import { BundleListDataSource } from './bundle-list-datasource';
@Component({
selector: 'app-bundle-list',
templateUrl: './bundle-list.component.html',
styleUrls: ['./bundle-list.component.css'],
imports: [
AsyncPipe,
CurrencyPipe,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
RouterModule,
],
})
export class BundleListComponent implements OnInit {
private route = inject(ActivatedRoute);
private toCsv = inject(ToCsvService);
searchFilter = new BehaviorSubject<string>('');
menuCategoryFilter = new BehaviorSubject<string>('');
data: BehaviorSubject<Bundle[]> = new BehaviorSubject<Bundle[]>([]);
dataSource: BundleListDataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data);
form: FormGroup<{
menuCategory: FormControl<string | null>;
filter: FormControl<string>;
}>;
list: Bundle[] = [];
menuCategories: MenuCategory[] = [];
displayedColumns: string[] = ['name', 'price', 'menuCategory', 'info', 'items'];
constructor() {
this.form = new FormGroup({
menuCategory: new FormControl<string | null>(''),
filter: new FormControl<string>('', { nonNullable: true }),
});
this.data.subscribe((data: Bundle[]) => {
this.list = data;
});
this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged()).subscribe((value) => {
this.searchFilter.next(value ?? '');
});
}
filterOn(val: string) {
this.menuCategoryFilter.next(val);
}
ngOnInit() {
this.dataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data);
this.route.data.subscribe((value) => {
const data = value as { list: Bundle[]; menuCategories: MenuCategory[] };
this.loadData(data.list, data.menuCategories);
});
}
loadData(list: Bundle[], menuCategories: MenuCategory[]) {
this.menuCategories = menuCategories;
this.data.next(list);
}
exportCsv() {
const headers = {
Name: 'name',
Units: 'units',
Price: 'salePrice',
MenuCategory: 'menuCategory',
Items: 'items',
};
// Map items to count for csv (simple)
const csvList = this.dataSource.filteredData.map((b) => ({
...b,
menuCategory: b.menuCategory?.name ?? '',
items: (b.items ?? [])
.map((i) => `${i.name}${i.quantity && i.quantity !== 1 ? ` x${i.quantity}` : ''}`)
.join(' | '),
}));
const csvData = new Blob([this.toCsv.toCsv(headers, csvList as unknown as Bundle[])], {
type: 'text/csv;charset=utf-8;',
});
const link = document.createElement('a');
link.href = window.URL.createObjectURL(csvData);
link.setAttribute('download', 'bundles.csv');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { BundleResolver } from './bundle-resolver.service';
describe('BundleResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BundleResolver],
});
});
it('should be created', inject([BundleResolver], (service: BundleResolver) => {
expect(service).toBeTruthy();
}));
});
+10
View File
@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { Bundle } from './bundle';
import { BundleService } from './bundle.service';
export const bundleResolver: ResolveFn<Bundle> = (route: ActivatedRouteSnapshot) => {
const id = route.paramMap.get('id');
return inject(BundleService).get(id);
};
@@ -0,0 +1,15 @@
import { inject, TestBed } from '@angular/core/testing';
import { BundleService } from './bundle.service';
describe('BundleService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BundleService],
});
});
it('should be created', inject([BundleService], (service: BundleService) => {
expect(service).toBeTruthy();
}));
});
+58
View File
@@ -0,0 +1,58 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { Bundle } from './bundle';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
};
const url = '/api/bundles';
const serviceName = 'BundleService';
@Injectable({ providedIn: 'root' })
export class BundleService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
list(): Observable<Bundle[]> {
return this.http
.get<Bundle[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Bundle[]>;
}
get(id: string | null): Observable<Bundle> {
const getUrl: string = id === null ? `${url}` : `${url}/${id}`;
return this.http
.get<Bundle>(getUrl)
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Bundle>;
}
save(bundle: Bundle): Observable<void> {
return this.http
.post<Bundle>(`${url}`, bundle, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable<void>;
}
update(bundle: Bundle): Observable<void> {
return this.http
.put<Bundle>(`${url}/${bundle.id}`, bundle, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable<void>;
}
saveOrUpdate(bundle: Bundle): Observable<void> {
if (!bundle.versionId) {
return this.save(bundle);
}
return this.update(bundle);
}
delete(id: string): Observable<void> {
return this.http
.delete<Bundle>(`${url}/${id}`, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<void>;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { MenuCategory } from '../core/menu-category';
export class BundleItem {
id?: string;
itemId: string;
name: string;
salePrice: number;
quantity: number;
public constructor(init?: Partial<BundleItem>) {
this.itemId = '';
this.name = '';
this.salePrice = 0;
this.quantity = 0;
Object.assign(this, init);
}
}
export class Bundle {
// backend: id_ (sku id), version_id (skuVersion id)
id?: string;
versionId?: string;
name: string;
units: string;
salePrice: number;
hasHappyHour: boolean;
isNotAvailable: boolean;
sortOrder: number;
menuCategory?: MenuCategory | null;
items: BundleItem[];
public constructor(init?: Partial<Bundle>) {
this.name = '';
this.units = '';
this.salePrice = 0;
this.hasHappyHour = false;
this.isNotAvailable = false;
this.sortOrder = 0;
this.menuCategory = null;
this.items = [];
Object.assign(this, init);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service';
import { menuCategoryListResolver } from '../menu-category/menu-category-list.resolver';
import { BundleDetailComponent } from './bundle-detail/bundle-detail.component';
import { bundleListResolver } from './bundle-list.resolver';
import { BundleListComponent } from './bundle-list/bundle-list.component';
import { bundleResolver } from './bundle.resolver';
export const routes: Routes = [
{
path: '',
component: BundleListComponent,
canActivate: [authGuard],
data: { permission: 'Products' }, // change if you have a separate permission
resolve: {
list: bundleListResolver,
menuCategories: menuCategoryListResolver,
},
},
{
path: 'new',
component: BundleDetailComponent,
canActivate: [authGuard],
data: { permission: 'Products' },
resolve: {
item: bundleResolver, // returns new Bundle() because id === null (no param here)
menuCategories: menuCategoryListResolver,
},
},
{
path: ':id',
component: BundleDetailComponent,
canActivate: [authGuard],
data: { permission: 'Products' },
resolve: {
item: bundleResolver,
menuCategories: menuCategoryListResolver,
},
},
];
@@ -2,39 +2,25 @@ import { MenuCategory } from './menu-category';
import { SaleCategory } from './sale-category';
import { Tax } from './tax';
export class StockKeepingUnit {
export class ProductQuery {
id: string | undefined;
versionId?: string;
name: string;
units: string;
menuCategory?: MenuCategory;
saleCategory?: SaleCategory;
price: number;
hasHappyHour: boolean;
isNotAvailable: boolean;
quantity: number;
isActive: boolean;
sortOrder: number;
menuCategory?: MenuCategory;
saleCategory?: SaleCategory;
enabled: boolean;
tax: Tax;
validFrom: string | null;
validTill: string | null;
public constructor(init?: Partial<StockKeepingUnit>) {
public constructor(init?: Partial<ProductQuery>) {
this.id = undefined;
this.name = '';
this.units = '';
this.price = 0;
this.hasHappyHour = false;
this.isNotAvailable = false;
this.quantity = 0;
this.isActive = true;
this.sortOrder = 0;
this.enabled = true;
this.validFrom = null;
this.validTill = null;
this.tax = new Tax();
Object.assign(this, init);
}
+5
View File
@@ -76,6 +76,11 @@
<h3>Products</h3>
</mat-card>
}
@if (auth.allowed('products')) {
<mat-card class="flex-col square-button" matRipple [routerLink]="['/', 'bundles']">
<h3>Bundles</h3>
</mat-card>
}
@if (auth.allowed('temporal-products')) {
<mat-card class="flex-col square-button" matRipple [routerLink]="['/', 'temporal-products']">
<h3>Temporal Products</h3>
@@ -10,7 +10,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@@ -37,7 +37,7 @@ import { ProductListDataSource } from './product-list-datasource';
MatSelectModule,
MatTableModule,
ReactiveFormsModule,
RouterLink,
RouterModule,
],
})
export class ProductListComponent implements OnInit {
+14 -13
View File
@@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { Product } from '../core/product';
import { ProductQuery } from '../core/product-query';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
@@ -31,18 +32,25 @@ export class ProductService {
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Product[]>;
}
listOfSaleCategory(id: string): Observable<Product[]> {
listOfSaleCategory(id: string): Observable<ProductQuery[]> {
const options = { params: new HttpParams().set('sc', id) };
return this.http
.get<Product[]>(`${url}/query`, options)
.pipe(catchError(this.log.handleError(serviceName, 'listOfSaleCategory'))) as Observable<Product[]>;
.get<ProductQuery[]>(`${url}/query`, options)
.pipe(catchError(this.log.handleError(serviceName, 'listOfSaleCategory'))) as Observable<ProductQuery[]>;
}
listIsActiveOfCategory(id: string): Observable<Product[]> {
listIsActiveOfCategory(id: string): Observable<ProductQuery[]> {
const options = { params: new HttpParams().set('mc', id) };
return this.http
.get<Product[]>(`${url}/query`, options)
.pipe(catchError(this.log.handleError(serviceName, 'listIsActiveOfCategory'))) as Observable<Product[]>;
.get<ProductQuery[]>(`${url}/query`, options)
.pipe(catchError(this.log.handleError(serviceName, 'listIsActiveOfCategory'))) as Observable<ProductQuery[]>;
}
query(query: string): Observable<ProductQuery[]> {
const options = { params: new HttpParams().set('q', query) };
return this.http
.get<ProductQuery[]>(`${url}/query`, options)
.pipe(catchError(this.log.handleError(serviceName, 'query'))) as Observable<ProductQuery[]>;
}
save(product: Product): Observable<void> {
@@ -75,11 +83,4 @@ export class ProductService {
.delete<Product>(`${url}/${id}`, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<void>;
}
balance(id: string, date: string): Observable<number> {
const options = { params: new HttpParams().set('d', date) };
return this.http
.get<number>(`${url}/balance`, options)
.pipe(catchError(this.log.handleError(serviceName, 'balance'))) as Observable<number>;
}
}
@@ -2,10 +2,10 @@ import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { Product } from '../core/product';
import { ProductQuery } from '../core/product-query';
import { ProductService } from '../product/product.service';
export const productListResolver: ResolveFn<Product[]> = (route) => {
export const productListResolver: ResolveFn<ProductQuery[]> = (route) => {
const id = route.paramMap.get('id');
if (id === null) {
return observableOf([]);
@@ -2,12 +2,12 @@ import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit';
import { ProductQuery } from '../../core/product-query';
export class SaleCategoryDetailDatasource extends DataSource<Product> {
private data: Product[] = [];
export class SaleCategoryDetailDatasource extends DataSource<ProductQuery> {
private data: ProductQuery[] = [];
constructor(private readonly dataObs: Observable<Product[]>) {
constructor(private readonly dataObs: Observable<ProductQuery[]>) {
super();
this.dataObs = dataObs.pipe(
tap((x) => {
@@ -16,7 +16,7 @@ export class SaleCategoryDetailDatasource extends DataSource<Product> {
);
}
connect(): Observable<Product[]> {
connect(): Observable<ProductQuery[]> {
return this.dataObs;
}
@@ -14,8 +14,8 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { round } from 'mathjs';
import { BehaviorSubject } from 'rxjs';
import { ProductQuery } from '../../core/product-query';
import { SaleCategory } from '../../core/sale-category';
import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit';
import { Tax } from '../../core/tax';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { SaleCategoryService } from '../sale-category.service';
@@ -55,7 +55,7 @@ export class SaleCategoryDetailComponent implements OnInit, AfterViewInit {
taxes: Tax[] = [];
item: SaleCategory = new SaleCategory();
products: BehaviorSubject<Product[]> = new BehaviorSubject<Product[]>([]);
products: BehaviorSubject<ProductQuery[]> = new BehaviorSubject<ProductQuery[]>([]);
dataSource: SaleCategoryDetailDatasource = new SaleCategoryDetailDatasource(this.products);
displayedColumns = ['name', 'price', 'menuCategory'];
@@ -70,7 +70,7 @@ export class SaleCategoryDetailComponent implements OnInit, AfterViewInit {
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { item: SaleCategory; taxes: Tax[]; products: Product[] };
const data = value as { item: SaleCategory; taxes: Tax[]; products: ProductQuery[] };
this.showItem(data.item);
this.taxes = data.taxes;
this.products.next(data.products);
+3 -3
View File
@@ -9,13 +9,13 @@ import { BillViewItem } from '../core/bill-view-item';
import { ModifierCategory } from '../core/modifier-category';
import { ReceivePaymentItem } from '../core/receive-payment-item';
import { SaleCategory } from '../core/sale-category';
import { StockKeepingUnit } from '../core/stock-keeping-unit';
import { Table } from '../core/table';
import { ModifierCategoryService } from '../modifier-categories/modifier-category.service';
import { MathService } from '../shared/math.service';
import { Bill } from './bills/bill';
import { Inventory } from './bills/inventory';
import { Kot } from './bills/kot';
import { ProductLink } from './bills/product-link';
import { VoucherType } from './bills/voucher-type';
import { VoucherService } from './bills/voucher.service';
import { ModifiersComponent } from './modifiers/modifiers.component';
@@ -109,7 +109,7 @@ export class BillService {
);
}
addSku(sku: StockKeepingUnit, quantity: number, discount: number): void {
addSku(sku: ProductLink, quantity: number, discount: number): void {
const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot;
const old = newKot.inventories.find((x) => x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour);
if (quantity < 0) {
@@ -127,7 +127,7 @@ export class BillService {
quantity,
price: sku.price,
isHappyHour: sku.hasHappyHour,
taxRate: sku.tax.rate,
taxRate: sku.tax?.rate,
tax: sku.tax,
discount,
modifiers: [],
+3 -3
View File
@@ -1,10 +1,10 @@
import { Modifier } from '../../core/modifier';
import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit';
import { Tax } from '../../core/tax';
import { ProductLink } from './product-link';
export class Inventory {
id: string | undefined;
sku: Product;
sku: ProductLink;
quantity: number;
price: number;
isHappyHour: boolean;
@@ -16,7 +16,7 @@ export class Inventory {
public constructor(init?: Partial<Inventory>) {
this.id = undefined;
this.sku = new Product();
this.sku = new ProductLink();
this.quantity = 0;
this.price = 0;
this.isHappyHour = false;
@@ -0,0 +1,21 @@
import { SaleCategory } from '../../core/sale-category';
import { Tax } from '../../core/tax';
export class ProductLink {
id: string | undefined;
name: string;
price: number;
tax: Tax | undefined;
saleCategory: SaleCategory | undefined;
hasHappyHour: boolean;
enabled: boolean;
public constructor(init?: Partial<ProductLink>) {
this.id = undefined;
this.name = '';
this.price = 0;
this.hasHappyHour = false;
this.enabled = false;
Object.assign(this, init);
}
}
@@ -4,8 +4,9 @@ import { MatCardModule } from '@angular/material/card';
import { MatRippleModule } from '@angular/material/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductQuery } from '../../core/product-query';
import { BillService } from '../bill.service';
import { Product } from '../product';
import { ProductLink } from '../bills/product-link';
@Component({
selector: 'app-products',
@@ -17,19 +18,19 @@ export class ProductsComponent implements OnInit {
private route = inject(ActivatedRoute);
private bs = inject(BillService);
list: Product[] = [];
list: ProductQuery[] = [];
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { list: Product[] };
const data = value as { list: ProductQuery[] };
this.list = data.list;
});
}
addSku(product: Product): void {
addSku(product: ProductQuery): void {
if (product.isNotAvailable) {
return;
}
this.bs.addSku(product, 1, 0);
this.bs.addSku(new ProductLink(product), 1, 0);
}
}
@@ -1,10 +1,10 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Product } from '../../core/product';
import { ProductQuery } from '../../core/product-query';
import { ProductService } from '../../product/product.service';
export const productsResolver: ResolveFn<Product[]> = (route) => {
export const productsResolver: ResolveFn<ProductQuery[]> = (route) => {
const id = route.paramMap.get('id') as string;
return inject(ProductService).listIsActiveOfCategory(id);
};
@@ -4,7 +4,6 @@ import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { StockKeepingUnit as Product } from '../core/stock-keeping-unit';
import { UpdateProductPrices } from './update-product-prices';
const url = '/api/update-product-prices';
@@ -31,7 +30,7 @@ export class UpdateProductPricesService {
save(item: UpdateProductPrices): Observable<UpdateProductPrices> {
const saveUrl: string = item.menuCategoryId === null ? url : `${url}/${item.menuCategoryId}`;
return this.http
.post<Product[]>(saveUrl, item)
.post<UpdateProductPrices>(saveUrl, item)
.pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable<UpdateProductPrices>;
}
}