Feature: Added product bundles. Have to wire it up to inventory and reports.
This commit is contained in:
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"))
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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_
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import enum
|
||||
|
||||
|
||||
class InventoryType(str, enum.Enum):
|
||||
regular = "regular"
|
||||
bundle = "bundle"
|
||||
bundle_item = "bundle_item"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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_
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user