diff --git a/barker/alembic/versions/1e0daf6bc1ae_combine_skus.py b/barker/alembic/versions/1e0daf6bc1ae_combine_skus.py
index fb6d411a..72d57ae0 100644
--- a/barker/alembic/versions/1e0daf6bc1ae_combine_skus.py
+++ b/barker/alembic/versions/1e0daf6bc1ae_combine_skus.py
@@ -281,6 +281,11 @@ def upgrade():
)
)
+ # Compatibility updates for GuestBookType enum
+ op.execute("ALTER TYPE guestbooktype ADD VALUE IF NOT EXISTS 'running'")
+ op.execute("ALTER TYPE guestbooktype ADD VALUE IF NOT EXISTS 'printed'")
+ op.execute("ALTER TYPE guestbooktype ADD VALUE IF NOT EXISTS 'old'")
+
def downgrade():
pass
diff --git a/barker/alembic/versions/32c508eed4df_skus.py b/barker/alembic/versions/32c508eed4df_skus.py
index 99e29962..d00ea762 100644
--- a/barker/alembic/versions/32c508eed4df_skus.py
+++ b/barker/alembic/versions/32c508eed4df_skus.py
@@ -60,6 +60,13 @@ def upgrade():
using="gist",
name=op.f("uq_sku_versions_sku_id"),
),
+ postgresql.ExcludeConstraint(
+ (sa.column("sku_id"), "="),
+ (sa.column("units"), "="),
+ (sa.text("daterange(valid_from, valid_till, '[]')"), "&&"),
+ using="gist",
+ name=op.f("uq_sku_versions_sku_id_units"),
+ ),
# postgresql.ExcludeConstraint((sa.column('units'), '='), (sa.text("daterange(valid_from, valid_till, '[]')"), '&&'), using='gist', name=op.f('uq_sku_versions_units')),
sa.ForeignKeyConstraint(
["menu_category_id"], ["menu_categories.id"], name=op.f("fk_sku_versions_menu_category_id_menu_categories")
@@ -68,7 +75,7 @@ def upgrade():
["sku_id"], ["stock_keeping_units.id"], name=op.f("fk_sku_versions_sku_id_stock_keeping_units")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_sku_versions")),
- sa.CheckConstraint("length(trim(units)) > 0", name="ck_sku_versions_units_not_blank"),
+ sa.CheckConstraint("length(trim(units)) >= 1", name="ck_sku_versions_units_not_blank"),
)
# ---------------------------------------------------------------------
@@ -178,7 +185,7 @@ def upgrade():
sa.select(
sa.func.gen_random_uuid(),
stock_keeping_units.c.id,
- sa.func.coalesce(sa.func.nullif(sa.func.btrim(pv.c.units), ""), sa.literal("pc")),
+ sa.func.coalesce(sa.func.nullif(sa.func.btrim(pv.c.units), ""), sa.literal("por")),
sa.cast(1.0, sa.Numeric(15, 5)),
sa.cast(1.0, sa.Numeric(15, 5)),
sa.cast(0.0, sa.Numeric(15, 2)),
@@ -215,6 +222,11 @@ def upgrade():
# ---------------------------------------------------------------------
op.alter_column("product_versions", "units", new_column_name="fraction_units")
op.drop_constraint(op.f("fk_products_menu_category_id_menu_categories"), "product_versions", type_="foreignkey")
+ op.create_check_constraint(
+ "ck_product_versions_name_not_blank",
+ "product_versions",
+ "length(trim(name)) >= 1",
+ )
for col in ["price", "quantity", "has_happy_hour", "is_not_available", "menu_category_id", "sort_order"]:
op.drop_column("product_versions", col)
diff --git a/barker/barker/models/guest_book_status.py b/barker/barker/models/guest_book_status.py
deleted file mode 100644
index 53b59bb2..00000000
--- a/barker/barker/models/guest_book_status.py
+++ /dev/null
@@ -1,10 +0,0 @@
-import enum
-
-
-class GuestBookStatus(str, enum.Enum):
- running = "running"
- printed = "printed"
- walk_in = "walk_in"
- booking = "booking"
- arrived = "arrived"
- old = "old"
diff --git a/barker/barker/models/guest_book_type.py b/barker/barker/models/guest_book_type.py
index b1e89c00..2d922220 100644
--- a/barker/barker/models/guest_book_type.py
+++ b/barker/barker/models/guest_book_type.py
@@ -2,6 +2,9 @@ import enum
class GuestBookType(str, enum.Enum):
+ running = "running"
+ printed = "printed"
walk_in = "walk_in"
booking = "booking"
arrived = "arrived"
+ old = "old"
diff --git a/barker/barker/models/sku_version.py b/barker/barker/models/sku_version.py
index 300ef9e4..2196458d 100644
--- a/barker/barker/models/sku_version.py
+++ b/barker/barker/models/sku_version.py
@@ -48,6 +48,7 @@ class SkuVersion:
__table_args__ = (
postgresql.ExcludeConstraint(
+ (sku_id, "="),
(units, "="),
(func.daterange(valid_from, valid_till, text("'[]'")), "&&"),
),
diff --git a/barker/barker/routers/__init__.py b/barker/barker/routers/__init__.py
index a5d16c30..c8de9746 100644
--- a/barker/barker/routers/__init__.py
+++ b/barker/barker/routers/__init__.py
@@ -1,6 +1,6 @@
from datetime import UTC, date, datetime, timedelta
-from barker.core.config import settings
+from ..core.config import settings
def query_date(d: str | None = None) -> date:
@@ -20,3 +20,15 @@ def effective_date(d: str | None = None) -> date:
if d is None
else datetime.strptime(d, "%d-%b-%Y").date()
)
+
+
+def dates_overlap(start1: date | None, end1: date | None, start2: date | None, end2: date | None) -> bool:
+ if start1 is None:
+ start1 = date.min
+ if start2 is None:
+ start2 = date.min
+ if end1 is None:
+ end1 = date.max
+ if end2 is None:
+ end2 = date.max
+ return start1 <= end2 and start2 <= end1
diff --git a/barker/barker/routers/guest_book.py b/barker/barker/routers/guest_book.py
index f79db474..0ac60fc7 100644
--- a/barker/barker/routers/guest_book.py
+++ b/barker/barker/routers/guest_book.py
@@ -12,7 +12,6 @@ from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
from ..models.customer import Customer
from ..models.guest_book import GuestBook
-from ..models.guest_book_status import GuestBookStatus
from ..models.guest_book_type import GuestBookType
from ..models.voucher import Voucher
from ..schemas import guest_book as schemas
@@ -118,10 +117,10 @@ def delete_route(
@router.get("", response_model=schemas.GuestBookIn)
def show_blank(
- t: GuestBookStatus | None,
+ t: GuestBookType | None,
user: UserToken = Security(get_user, scopes=["customers"]),
) -> schemas.GuestBookIn:
- return blank_guest_book_info(t or GuestBookStatus.walk_in)
+ return blank_guest_book_info(t or GuestBookType.walk_in)
@router.get("/list", response_model=schemas.GuestBookList)
@@ -147,9 +146,7 @@ def show_list(
with SessionFuture() as db:
count = 0
for i, item in enumerate(db.execute(list_).scalars().all()):
- status = (
- GuestBookStatus(item.type_.name) if item.status is None else GuestBookStatus(item.status.status.name)
- )
+ status = GuestBookType(item.type_.name) if item.status is None else GuestBookType(item.status.status.name)
if item.type_ != GuestBookType.booking:
count += item.pax
gbli = schemas.GuestBookListItem(
@@ -179,7 +176,7 @@ def show_list(
.first()
)
if last is not None:
- gbli.status = GuestBookStatus.old
+ gbli.status = GuestBookType.old
gbli.voucher_id = last.id
gbli.table_name = last.food_table.name
guest_book.insert(0, gbli)
@@ -210,14 +207,14 @@ def guest_book_info(item: GuestBook) -> schemas.GuestBook:
arrival_date=arrival_date,
last_edit_date=item.last_edit_date + td,
notes=item.notes,
- type_=GuestBookStatus(item.type_.name),
+ type_=GuestBookType(item.type_.name),
)
-def blank_guest_book_info(type_: GuestBookStatus) -> schemas.GuestBookIn:
+def blank_guest_book_info(type_: GuestBookType) -> schemas.GuestBookIn:
now = datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)
- booking_date = None if type_ != GuestBookStatus.booking else now
- arrival_date = None if type_ != GuestBookStatus.walk_in else now
+ booking_date = None if type_ != GuestBookType.booking else now
+ arrival_date = None if type_ != GuestBookType.walk_in else now
return schemas.GuestBookIn(
name="",
phone="",
diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py
index 112da54a..7bdaa4b4 100644
--- a/barker/barker/routers/product.py
+++ b/barker/barker/routers/product.py
@@ -41,6 +41,7 @@ def sort_order(
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]),
) -> list[schemas.Product]:
+ raise NotImplementedError("Sorting products is not yet implemented.")
try:
with SessionFuture() as db:
indexes: dict[uuid.UUID, int] = {}
@@ -536,7 +537,7 @@ def show_term(
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, products=[]
+ id_=item.menu_category_id, name=item.menu_category.name, skus=[]
),
price=item.sale_price,
has_happy_hour=item.has_happy_hour,
@@ -595,7 +596,8 @@ def show_id(
or_(ProductVersion.valid_till == None, ProductVersion.valid_till >= date_), # noqa: E711
)
- sv_active = and_(
+ 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
)
@@ -606,7 +608,7 @@ def show_id(
.join(ProductVersion.sale_category)
.join(ProductVersion.product)
.join(Product.skus)
- .join(StockKeepingUnit.versions.and_(sv_active))
+ .join(SkuVersion, onclause=sku_version_onclause)
.join(SkuVersion.menu_category)
.where(pv_active)
.order_by(
@@ -669,7 +671,7 @@ def product_info(version: ProductVersion) -> schemas.Product:
has_happy_hour=sku_version.has_happy_hour,
is_not_available=sku_version.sku.is_not_available,
menu_category=MenuCategoryLink(
- id_=sku_version.menu_category_id, name=sku_version.menu_category.name, products=[]
+ id_=sku_version.menu_category_id, name=sku_version.menu_category.name, skus=[]
),
sort_order=sku_version.sku.sort_order,
)
diff --git a/barker/barker/routers/reports/__init__.py b/barker/barker/routers/reports/__init__.py
index 12f0c940..9093c8c6 100644
--- a/barker/barker/routers/reports/__init__.py
+++ b/barker/barker/routers/reports/__init__.py
@@ -2,7 +2,7 @@ from datetime import UTC, date, datetime, timedelta
from fastapi import HTTPException, status
-from barker.core.config import settings
+from ...core.config import settings
def report_start_date(s: str | None = None) -> date:
diff --git a/barker/barker/routers/temporal_product.py b/barker/barker/routers/temporal_product.py
index 153a6104..78f63f8b 100644
--- a/barker/barker/routers/temporal_product.py
+++ b/barker/barker/routers/temporal_product.py
@@ -1,9 +1,10 @@
import uuid
+from collections import defaultdict
from datetime import date, timedelta
from fastapi import APIRouter, HTTPException, Security, status
-from sqlalchemy import Date, and_, delete, distinct, or_, select, update
+from sqlalchemy import Date, delete, nullsfirst, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, contains_eager
from sqlalchemy.sql.functions import count, func
@@ -17,56 +18,67 @@ from ..models.menu_category import MenuCategory
from ..models.modifier_category_product import ModifierCategoryProduct
from ..models.product import Product
from ..models.product_version import ProductVersion
-from ..models.sale_category import SaleCategory
+from ..models.sku_version import SkuVersion
+from ..models.stock_keeping_unit import StockKeepingUnit
from ..models.voucher import Voucher
-from ..schemas import product as schemas
+from ..schemas.menu_category import MenuCategoryLink
+from ..schemas.sale_category import SaleCategoryLink
+from ..schemas.temporal_product import Product as ProductModel
+from ..schemas.temporal_product import StockKeepingUnit as SkuModel
+from ..schemas.temporal_product import TemporalProduct
from ..schemas.user_token import UserToken
router = APIRouter()
-@router.put("/{version_id}", response_model=None)
+@router.put("/{id_}", response_model=None)
def update_route(
- version_id: uuid.UUID,
- data: schemas.Product,
+ id_: uuid.UUID,
+ data: TemporalProduct,
user: UserToken = Security(get_user, scopes=["temporal-products"]),
) -> None:
try:
+ data.products.sort(key=lambda p: (p.valid_from or date.min, p.valid_till or date.max))
+ data.skus.sort(key=lambda s: (s.valid_from or date.min, s.valid_till or date.max))
with SessionFuture() as db:
- old: ProductVersion = db.execute(select(ProductVersion).where(ProductVersion.id == version_id)).scalar_one()
- if (
- old.product_id != data.id_
- or old.name != data.name
- or old.units != data.units
- or old.valid_from != data.valid_from
- or old.valid_till != data.valid_till
- ):
- check_product(old, data, db)
- check_inventories(old, data, db)
- update_inventories(old, data, db)
- old.product_id = data.id_
- old.name = data.name
- old.units = data.units
- old.menu_category_id = data.menu_category.id_
- old.sale_category_id = data.sale_category.id_
- old.price = data.price
- old.has_happy_hour = data.has_happy_hour
- old.is_not_available = data.is_not_available
- old.quantity = data.quantity
- old.valid_from = data.valid_from
- old.valid_till = data.valid_till
- db.flush()
- db.execute(
- delete(ModifierCategoryProduct)
- .where(~ModifierCategoryProduct.product_id.in_(select(distinct(ProductVersion.product_id))))
- .execution_options(synchronize_session=False)
- )
- db.execute(
- delete(Product)
- .where(~Product.id.in_(select(distinct(ProductVersion.product_id))))
- .execution_options(synchronize_session=False)
+ check_gaps(data, db)
+ check_inventories(data, db)
+ product = db.execute(select(Product).where(Product.id == id_)).scalar_one()
+ for product_v in product.versions:
+ data_version = next((p for p in data.products if p.version_id == product_v.id), None)
+ if data_version is None:
+ # Delete version
+ db.delete(product_v)
+ else:
+ data.products.remove(data_version)
+ product_v.name = data_version.name
+ product_v.fraction_units = data_version.fraction_units
+ product_v.sale_category_id = data_version.sale_category.id_
+ product_v.valid_from = data_version.valid_from
+ product_v.valid_till = data_version.valid_till
+ skus = (
+ db.execute(select(SkuVersion).join(SkuVersion.sku).where(StockKeepingUnit.product_id == id_))
+ .scalars()
+ .all()
)
+
+ for sku_v in skus:
+ data_sku = next((s for s in data.skus if s.version_id == sku_v.id), None)
+ if data_sku is None:
+ # Delete sku version
+ db.delete(sku_v)
+ else:
+ data.skus.remove(data_sku)
+ sku_v.units = data_sku.units
+ sku_v.fraction = data_sku.fraction
+ sku_v.product_yield = data_sku.product_yield
+ sku_v.cost_price = data_sku.cost_price
+ sku_v.sale_price = data_sku.sale_price
+ sku_v.has_happy_hour = data_sku.has_happy_hour
+ sku_v.menu_category_id = data_sku.menu_category.id_
+ sku_v.valid_from = data_sku.valid_from
+ sku_v.valid_till = data_sku.valid_till
db.commit()
return
except SQLAlchemyError as e:
@@ -76,92 +88,78 @@ def update_route(
)
-def check_product(old: ProductVersion, data: schemas.Product, db: Session) -> None:
- query = select(count(ProductVersion.id)).where(ProductVersion.id != old.id)
- if data.valid_from is not None:
- query = query.where(ProductVersion.valid_till >= data.valid_from)
- if data.valid_till is not None:
- query = query.where(ProductVersion.valid_from <= data.valid_till)
- query = query.where(
- or_(
- ProductVersion.product_id == data.id_,
- and_(ProductVersion.name == data.name, ProductVersion.units == data.units),
- )
- )
- if db.execute(query).scalar_one() > 0:
- raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="Overlapping product exists",
- )
+def check_gaps(data: TemporalProduct, db: Session) -> None:
+ skus: dict[uuid.UUID, list[SkuModel]] = defaultdict(list)
+ for sku in data.skus:
+ skus[sku.id_].append(sku)
+ for sku_list in skus.values():
+ if (
+ data.products[0].valid_from != sku_list[0].valid_from
+ or data.products[-1].valid_till != sku_list[-1].valid_till
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Product and SKU versions validity do not match",
+ )
+ for i, p_item in enumerate(data.products[1:], start=1):
+ if data.products[i - 1].valid_till + timedelta(days=1) != p_item.valid_from:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Gaps in product versions exist",
+ )
+ for sku_list in skus.values():
+ for i, s_item in enumerate(sku_list[1:], start=1):
+ if sku_list[i - 1].valid_till + timedelta(days=1) != s_item.valid_from:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Gaps in sku versions exist",
+ )
-def check_inventories(old: ProductVersion, data: schemas.Product, db: Session) -> None:
+def check_inventories(data: TemporalProduct, db: Session) -> None:
+ if data.products[0].valid_from is None and data.products[-1].valid_till is None:
+ return
day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day")
- if data.valid_from is not None and (old.valid_from or date.min) < data.valid_from:
- query = select(count(Inventory.id)).where(Inventory.product_id == old.product_id)
- if old.valid_from is not None:
- query = query.where(day >= old.valid_from)
- query = query.where(day < data.valid_from)
- if db.execute(query).scalar_one() > 0:
- raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="Changing of validity will orphan inventories",
- )
+ skus = [s.id_ for s in data.skus]
+ query = (
+ select(count(Inventory.id))
+ .select_from(Inventory)
+ .join(Inventory.kot)
+ .join(Kot.voucher)
+ .where(Inventory.sku_id.in_(skus))
+ )
- if data.valid_till is not None and (old.valid_till or date.max) > data.valid_till:
- query = select(count(Inventory.id)).where(Inventory.product_id == old.product_id)
- if old.valid_till is not None:
- query = query.where(day <= old.valid_till)
- query = query.where(day > data.valid_till)
- if db.execute(query).scalar_one() > 0:
- raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="Changing of validity will orphan inventories",
- )
+ conditions = []
+ if data.products[0].valid_from is not None:
+ conditions.append(day < data.products[0].valid_from)
+ if data.products[-1].valid_till is not None:
+ conditions.append(day > data.products[-1].valid_till)
+ if conditions:
+ query = query.where(or_(*conditions))
-def update_inventories(old: ProductVersion, data: schemas.Product, db: Session) -> None:
- if old.product_id != data.id_:
- day = func.cast(
- Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
- ).label("day")
- invs = select(Inventory.id).join(Inventory.kot).join(Kot.voucher).where(Inventory.product_id == old.product_id)
- if old.valid_from is not None:
- invs = invs.where(day >= old.valid_from)
- if old.valid_till is not None:
- invs = invs.where(day <= old.valid_till)
- db.execute(
- update(Inventory)
- .values(product_id=data.id_)
- .where(Inventory.id.in_(invs))
- .execution_options(synchronize_session=False)
+ print(query)
+ if db.execute(query).scalar_one() > 0:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Changing of validity will orphan inventories",
)
-@router.delete("/{version_id}", response_model=None)
+@router.delete("/{id_}", response_model=None)
def delete_route(
- version_id: uuid.UUID,
+ id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["temporal-products"]),
) -> None:
with SessionFuture() as db:
- id_ = db.execute(
- select(ProductVersion.product_id).where(ProductVersion.id == version_id).group_by(ProductVersion.product_id)
+ invs = db.execute(
+ select(count(Inventory.id)).where(
+ Inventory.sku_id.in_(select(StockKeepingUnit.id).where(StockKeepingUnit.product_id == id_))
+ )
).scalar_one()
- valid_from, valid_till = db.execute(
- select(ProductVersion.valid_from, ProductVersion.valid_till).where(ProductVersion.id == version_id)
- ).one()
- day = func.cast(
- Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
- ).label("day")
- query = select(count(Inventory.id)).join(Inventory.kot).join(Kot.voucher).where(Inventory.product_id == id_)
- if valid_from is not None:
- query = query.where(day >= valid_from)
- if valid_till is not None:
- query = query.where(day <= valid_till)
- invs = db.execute(query).scalar_one()
if invs > 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -169,99 +167,128 @@ def delete_route(
)
db.execute(
- delete(ProductVersion).where(ProductVersion.id == version_id).execution_options(synchronize_session=False)
+ delete(ProductVersion).where(ProductVersion.product_id == id_).execution_options(synchronize_session=False)
+ )
+ db.execute(
+ delete(SkuVersion)
+ .where(SkuVersion.sku_id.in_(select(StockKeepingUnit.id).where(StockKeepingUnit.product_id == id_)))
+ .execution_options(synchronize_session=False)
+ )
+ db.execute(
+ delete(StockKeepingUnit)
+ .where(StockKeepingUnit.product_id == id_)
+ .execution_options(synchronize_session=False)
)
db.execute(
delete(ModifierCategoryProduct)
- .where(~ModifierCategoryProduct.product_id.in_(select(distinct(ProductVersion.product_id))))
- .execution_options(synchronize_session=False)
- )
- db.execute(
- delete(Product)
- .where(~Product.id.in_(select(distinct(ProductVersion.product_id))))
+ .where(ModifierCategoryProduct.product_id == id_)
.execution_options(synchronize_session=False)
)
+ db.execute(delete(Product).where(Product.id == id_).execution_options(synchronize_session=False))
db.commit()
return
-@router.get("/list", response_model=list[list[schemas.Product]])
-def show_list(user: UserToken = Security(get_user, scopes=["temporal-products"])) -> list[list[schemas.Product]]:
+@router.get("/list", response_model=list[TemporalProduct])
+def show_list(user: UserToken = Security(get_user, scopes=["temporal-products"])) -> list[TemporalProduct]:
with SessionFuture() as db:
return product_list(db)
-def product_list(db: Session) -> list[list[schemas.Product]]:
- dict_: dict[uuid.UUID, list[schemas.Product]] = {}
+def product_list(db: Session) -> list[TemporalProduct]:
list_ = (
db.execute(
- select(ProductVersion)
- .join(ProductVersion.menu_category)
+ select(Product)
+ .join(Product.versions)
.join(ProductVersion.sale_category)
+ .join(Product.skus)
+ .join(StockKeepingUnit.versions)
+ .join(SkuVersion.menu_category)
.order_by(MenuCategory.sort_order)
.order_by(MenuCategory.name)
- .order_by(ProductVersion.sort_order)
+ .order_by(Product.sort_order)
.order_by(ProductVersion.name)
+ .order_by(nullsfirst(ProductVersion.valid_from))
+ .order_by(nullsfirst(SkuVersion.valid_from))
.options(
- contains_eager(ProductVersion.menu_category),
- contains_eager(ProductVersion.sale_category),
+ contains_eager(Product.versions).contains_eager(ProductVersion.sale_category),
+ contains_eager(Product.skus)
+ .contains_eager(StockKeepingUnit.versions)
+ .contains_eager(SkuVersion.menu_category),
)
)
.unique()
.scalars()
.all()
)
- for item in list_:
- if item.product_id not in dict_:
- dict_[item.product_id] = []
- dict_[item.product_id].append(product_info(item))
- dict_[item.product_id] = sorted(dict_[item.product_id], key=lambda k: k.valid_from or date.min)
- return list(dict_.values())
+ return [product_info(item) for item in list_]
-@router.get("/{version_id}", response_model=schemas.Product)
+@router.get("/{id_}", response_model=TemporalProduct)
def show_id(
- version_id: uuid.UUID,
+ id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["products"]),
-) -> schemas.Product:
+) -> TemporalProduct:
with SessionFuture() as db:
item = (
db.execute(
- select(ProductVersion)
- .join(ProductVersion.menu_category)
+ select(Product)
+ .join(Product.versions)
.join(ProductVersion.sale_category)
- .join(SaleCategory.tax)
- .where(ProductVersion.id == version_id)
- .order_by(ProductVersion.valid_till)
+ .join(Product.skus)
+ .join(StockKeepingUnit.versions)
+ .join(SkuVersion.menu_category)
+ .where(Product.id == id_)
+ .order_by(nullsfirst(ProductVersion.valid_from))
+ .order_by(nullsfirst(SkuVersion.valid_from))
.options(
- contains_eager(ProductVersion.menu_category),
- contains_eager(ProductVersion.sale_category),
- contains_eager(ProductVersion.sale_category).contains_eager(SaleCategory.tax),
+ contains_eager(Product.versions).contains_eager(ProductVersion.sale_category),
+ contains_eager(Product.skus)
+ .contains_eager(StockKeepingUnit.versions)
+ .contains_eager(SkuVersion.menu_category),
)
)
.unique()
- .scalar_one()
+ .scalars()
+ .one()
)
- return product_info(item)
+ return product_info(item)
-def product_info(item: ProductVersion) -> schemas.Product:
- return schemas.Product(
- id_=item.product_id,
- version_id=item.id,
- name=item.name,
- units=item.units,
- menu_category=schemas.MenuCategoryLink(id_=item.menu_category_id, name=item.menu_category.name, products=[]),
- sale_category=schemas.SaleCategoryLink(
- id_=item.sale_category_id,
- name=item.sale_category.name,
- ),
- price=item.price,
- has_happy_hour=item.has_happy_hour,
- is_not_available=item.is_not_available,
- quantity=item.quantity,
- is_active=True,
- sort_order=item.sort_order,
- valid_from=item.valid_from,
- valid_till=item.valid_till,
- )
+def product_info(product: Product) -> TemporalProduct:
+ tp = TemporalProduct(products=[], skus=[])
+ for version in product.versions:
+ tp.products.append(
+ ProductModel(
+ id_=version.product_id,
+ version_id=version.id,
+ name=version.name,
+ fraction_units=version.fraction_units,
+ sale_category=SaleCategoryLink(
+ id_=version.sale_category_id,
+ name=version.sale_category.name,
+ ),
+ sort_order=version.product.sort_order,
+ valid_from=version.valid_from,
+ valid_till=version.valid_till,
+ )
+ )
+ for sku_v in (sku_v for sku in product.skus for sku_v in sku.versions):
+ tp.skus.append(
+ SkuModel(
+ id_=sku_v.sku_id,
+ version_id=sku_v.id,
+ units=sku_v.units,
+ fraction=sku_v.fraction,
+ product_yield=sku_v.product_yield,
+ cost_price=sku_v.cost_price,
+ sale_price=sku_v.sale_price,
+ has_happy_hour=sku_v.has_happy_hour,
+ is_not_available=sku_v.sku.is_not_available,
+ menu_category=MenuCategoryLink(id_=sku_v.menu_category_id, name=sku_v.menu_category.name, skus=[]),
+ sort_order=sku_v.sku.sort_order,
+ valid_from=sku_v.valid_from,
+ valid_till=sku_v.valid_till,
+ )
+ )
+ return tp
diff --git a/barker/barker/routers/voucher/save.py b/barker/barker/routers/voucher/save.py
index cc5cd30a..79472ed9 100644
--- a/barker/barker/routers/voucher/save.py
+++ b/barker/barker/routers/voucher/save.py
@@ -82,6 +82,22 @@ def do_save(
) -> Voucher:
now = datetime.now(UTC).replace(tzinfo=None)
product_date = (now + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES)).date()
+ product_version_onclause = and_(
+ ProductVersion.product_id == Product.id,
+ or_(
+ ProductVersion.valid_from == None, # noqa: E711
+ ProductVersion.valid_from <= product_date,
+ ),
+ or_(
+ ProductVersion.valid_till == None, # noqa: E711
+ ProductVersion.valid_till >= product_date,
+ ),
+ )
+ sku_version_onclause = and_(
+ SkuVersion.sku_id == StockKeepingUnit.id,
+ or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= product_date), # noqa: E711
+ or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= product_date), # noqa: E711
+ )
check_permissions(None, voucher_type, user.permissions)
kot_id = db.execute(select(func.coalesce(func.max(Voucher.kot_id), 0) + 1)).scalar_one()
@@ -126,33 +142,11 @@ def do_save(
sku: StockKeepingUnit = (
db.execute(
select(StockKeepingUnit)
- .join(StockKeepingUnit.versions)
+ .join(SkuVersion, onclause=sku_version_onclause)
.join(StockKeepingUnit.product)
- .join(Product.versions)
+ .join(ProductVersion, onclause=product_version_onclause)
.join(ProductVersion.sale_category)
- .where(
- and_(
- StockKeepingUnit.id == i.sku.id_,
- or_(
- SkuVersion.valid_from == None, # noqa: E711
- SkuVersion.valid_from <= product_date,
- ),
- or_(
- SkuVersion.valid_till == None, # noqa: E711
- SkuVersion.valid_till >= product_date,
- ),
- and_(
- or_(
- ProductVersion.valid_from == None, # noqa: E711
- ProductVersion.valid_from <= product_date,
- ),
- or_(
- ProductVersion.valid_till == None, # noqa: E711
- ProductVersion.valid_till >= product_date,
- ),
- ),
- )
- )
+ .where(StockKeepingUnit.id == i.sku.id_)
.options(
contains_eager(StockKeepingUnit.versions),
contains_eager(StockKeepingUnit.product)
diff --git a/barker/barker/routers/voucher/split.py b/barker/barker/routers/voucher/split.py
index de4134e5..9dc0f4ef 100644
--- a/barker/barker/routers/voucher/split.py
+++ b/barker/barker/routers/voucher/split.py
@@ -130,15 +130,15 @@ def save(
db.flush()
for old_inventory in split_inventories:
inv = Inventory(
- kot.id,
- old_inventory.product_id,
- old_inventory.quantity,
- old_inventory.price,
- old_inventory.discount,
- old_inventory.is_happy_hour,
- old_inventory.tax_id,
- old_inventory.tax_rate,
- old_inventory.sort_order,
+ kot_id=kot.id,
+ sku_id=old_inventory.sku_id,
+ quantity=old_inventory.quantity,
+ price=old_inventory.price,
+ discount=old_inventory.discount,
+ is_hh=old_inventory.is_happy_hour,
+ tax_id=old_inventory.tax_id,
+ tax_rate=old_inventory.tax_rate,
+ sort_order=old_inventory.sort_order,
)
kot.inventories.append(inv)
db.add(inv)
@@ -161,16 +161,16 @@ def split_into_kots(inventories: list[Inventory]) -> list[list[Inventory]]:
def happy_hour_items_balanced(inventories: list[Inventory]) -> bool:
- happy = set((i.product_id, i.quantity) for i in inventories if i.is_happy_hour)
- products = set(i.product_id for i in inventories if i.is_happy_hour)
- other = set((i.product_id, i.quantity) for i in inventories if not i.is_happy_hour and i.product_id in products)
+ happy = set((i.sku_id, i.quantity) for i in inventories if i.is_happy_hour)
+ products = set(i.sku_id for i in inventories if i.is_happy_hour)
+ other = set((i.sku_id, i.quantity) for i in inventories if not i.is_happy_hour and i.sku_id in products)
return happy == other
def are_product_quantities_positive(inventories: list[Inventory]) -> bool:
quantities: dict[tuple[uuid.UUID, bool], Decimal] = defaultdict(Decimal)
for i in inventories:
- key = (i.product_id, i.is_happy_hour)
+ key = (i.sku_id, i.is_happy_hour)
quantities[key] += i.quantity
return all(j >= 0 for j in quantities.values())
@@ -178,10 +178,10 @@ def are_product_quantities_positive(inventories: list[Inventory]) -> bool:
def happy_hour_items_more_than_regular(invs: list[Inventory]) -> bool:
inventories = {}
for inventory in invs:
- if inventory.product_id not in inventories:
- inventories[inventory.product_id] = {"normal": Decimal(0), "happy": Decimal(0)}
+ if inventory.sku_id not in inventories:
+ inventories[inventory.sku_id] = {"normal": Decimal(0), "happy": Decimal(0)}
if inventory.is_happy_hour:
- inventories[inventory.product_id]["happy"] += inventory.quantity
+ inventories[inventory.sku_id]["happy"] += inventory.quantity
else:
- inventories[inventory.product_id]["normal"] += inventory.quantity
+ inventories[inventory.sku_id]["normal"] += inventory.quantity
return any(value["happy"] > value["normal"] for value in inventories.values())
diff --git a/barker/barker/routers/voucher/update.py b/barker/barker/routers/voucher/update.py
index 4ea0ec4c..67bd2d78 100644
--- a/barker/barker/routers/voucher/update.py
+++ b/barker/barker/routers/voucher/update.py
@@ -55,6 +55,22 @@ def update_route(
product_date = (
now + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES)
).date()
+ product_version_onclause = and_(
+ ProductVersion.product_id == Product.id,
+ or_(
+ ProductVersion.valid_from == None, # noqa: E711
+ ProductVersion.valid_from <= product_date,
+ ),
+ or_(
+ ProductVersion.valid_till == None, # noqa: E711
+ ProductVersion.valid_till >= product_date,
+ ),
+ )
+ sku_version_onclause = and_(
+ SkuVersion.sku_id == StockKeepingUnit.id,
+ or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= product_date), # noqa: E711
+ or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= product_date), # noqa: E711
+ )
update_table = u
voucher_type = VoucherType(p)
guest_book = get_guest_book(g, db)
@@ -125,32 +141,10 @@ def update_route(
for index, nki in enumerate(nk.inventories):
sku: StockKeepingUnit = db.execute(
select(StockKeepingUnit)
- .join(StockKeepingUnit.versions)
+ .join(SkuVersion, onclause=sku_version_onclause)
.join(StockKeepingUnit.product)
- .join(Product.versions)
- .where(
- and_(
- StockKeepingUnit.id == nki.sku.id_,
- or_(
- SkuVersion.valid_from == None, # noqa: E711
- SkuVersion.valid_from <= product_date,
- ),
- or_(
- SkuVersion.valid_till == None, # noqa: E711
- SkuVersion.valid_till >= product_date,
- ),
- and_(
- or_(
- ProductVersion.valid_from == None, # noqa: E711
- ProductVersion.valid_from <= product_date,
- ),
- or_(
- ProductVersion.valid_till == None, # noqa: E711
- ProductVersion.valid_till >= product_date,
- ),
- ),
- )
- )
+ .join(ProductVersion, onclause=product_version_onclause)
+ .where(StockKeepingUnit.id == nki.sku.id_)
.options(
contains_eager(StockKeepingUnit.versions),
contains_eager(StockKeepingUnit.product).contains_eager(Product.versions),
diff --git a/barker/barker/schemas/guest_book.py b/barker/barker/schemas/guest_book.py
index 4bc58676..7827364b 100644
--- a/barker/barker/schemas/guest_book.py
+++ b/barker/barker/schemas/guest_book.py
@@ -12,7 +12,7 @@ from pydantic import (
model_validator,
)
-from ..models.guest_book_status import GuestBookStatus
+from ..models.guest_book_type import GuestBookType
from . import to_camel
@@ -24,7 +24,7 @@ class GuestBookIn(BaseModel):
booking_date: datetime | None = None
arrival_date: datetime | None = None
notes: str | None = None
- type_: GuestBookStatus
+ type_: GuestBookType
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
@@ -90,7 +90,7 @@ class GuestBookListItem(BaseModel):
arrival_date: datetime | None = None
last_edit_date: datetime
- status: GuestBookStatus
+ status: GuestBookType
table_id: uuid.UUID | None = None
voucher_id: uuid.UUID | None = None
table_name: str | None = None
diff --git a/barker/barker/schemas/temporal_product.py b/barker/barker/schemas/temporal_product.py
new file mode 100644
index 00000000..b50437a3
--- /dev/null
+++ b/barker/barker/schemas/temporal_product.py
@@ -0,0 +1,101 @@
+import uuid
+
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
+
+from . import Daf, to_camel
+from .menu_category import MenuCategoryLink
+from .sale_category import SaleCategoryLink
+
+
+class Product(BaseModel):
+ id_: uuid.UUID
+ version_id: uuid.UUID
+ name: str = Field(..., min_length=1)
+ fraction_units: str = Field(..., min_length=1)
+ sale_category: SaleCategoryLink = Field(...)
+ sort_order: int
+
+ valid_from: date | None = None
+ valid_till: date | None = None
+ model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
+
+ @field_validator("valid_from", mode="before")
+ @classmethod
+ def parse_valid_from(cls, value: str | date | None) -> date | None:
+ if value is None:
+ return None
+ if isinstance(value, date):
+ return value
+ return datetime.strptime(value, "%d-%b-%Y").date()
+
+ @field_serializer("valid_from")
+ def serialize_valid_from(self, value: date | None, _info: Any) -> str | None:
+ return None if value is None else value.strftime("%d-%b-%Y")
+
+ @field_validator("valid_till", mode="before")
+ @classmethod
+ def parse_valid_till(cls, value: str | date | None) -> date | None:
+ if value is None:
+ return None
+ if isinstance(value, date):
+ return value
+ return datetime.strptime(value, "%d-%b-%Y").date()
+
+ @field_serializer("valid_till")
+ def serialize_valid_till(self, value: date | None, _info: Any) -> str | None:
+ return None if value is None else value.strftime("%d-%b-%Y")
+
+
+class StockKeepingUnit(BaseModel):
+ id_: uuid.UUID = None
+ version_id: uuid.UUID = None
+ units: str = Field(..., min_length=1)
+ fraction: Daf = Field(ge=Decimal(1), default=Decimal(1))
+ product_yield: Daf = Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))
+ cost_price: Daf = Field(ge=Decimal(0), default=Decimal(0))
+ sale_price: Daf = Field(ge=Decimal(0), default=Decimal(0))
+ menu_category: MenuCategoryLink = Field(...)
+ sort_order: int = Field(ge=0, default=0)
+
+ has_happy_hour: bool
+ is_not_available: bool
+
+ valid_from: date | None = None
+ valid_till: date | None = None
+ model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
+
+ @field_validator("valid_from", mode="before")
+ @classmethod
+ def parse_valid_from(cls, value: str | date | None) -> date | None:
+ if value is None:
+ return None
+ if isinstance(value, date):
+ return value
+ return datetime.strptime(value, "%d-%b-%Y").date()
+
+ @field_serializer("valid_from")
+ def serialize_valid_from(self, value: date | None, _info: Any) -> str | None:
+ return None if value is None else value.strftime("%d-%b-%Y")
+
+ @field_validator("valid_till", mode="before")
+ @classmethod
+ def parse_valid_till(cls, value: str | date | None) -> date | None:
+ if value is None:
+ return None
+ if isinstance(value, date):
+ return value
+ return datetime.strptime(value, "%d-%b-%Y").date()
+
+ @field_serializer("valid_till")
+ def serialize_valid_till(self, value: date | None, _info: Any) -> str | None:
+ return None if value is None else value.strftime("%d-%b-%Y")
+
+
+class TemporalProduct(BaseModel):
+ products: list[Product] = Field(...)
+ skus: list[StockKeepingUnit] = Field(...)
+ model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css
index e69de29b..e43f2ea8 100644
--- a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css
+++ b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.css
@@ -0,0 +1,30 @@
+.two-col {
+ display: flex;
+ gap: 16px;
+}
+
+.col {
+ flex: 1;
+ min-width: 0;
+}
+
+.card {
+ padding: 12px;
+ border-radius: 12px;
+ margin-bottom: 12px;
+}
+
+.full-width {
+ width: 100%;
+}
+
+.buttons {
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.backend-actions {
+ margin-top: 16px;
+ justify-content: flex-end;
+ gap: 12px;
+}
diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html
index 04706a23..9ee2d2ea 100644
--- a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html
+++ b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.html
@@ -1,73 +1,190 @@
Product
-
+
+
+
Product Versions
+
+
+
+ | Name |
+ {{ p.name }} |
+
+
+ Fraction Units |
+ {{ p.fraction_units }} |
+
+
+
+ Sale Category |
+ {{ p.saleCategory?.name }} |
+
+
+
+ Valid From |
+ {{ p.validFrom }} |
+
+
+
+ Valid Till |
+ {{ p.validTill }} |
+
+
+
+ Actions |
+
+
+ |
+
+
+
+
+
+
+
+
SKU Versions
+
+
+
+ | Units |
+ {{ s.units }} |
+
+
+
+ Sale Price |
+ {{ s.salePrice }} |
+
+
+
+ Menu Category |
+ {{ s.menuCategory?.name }} |
+
+
+
+ Valid From |
+ {{ s.validFrom }} |
+
+
+
+ Valid Till |
+ {{ s.validTill }} |
+
+
+
+ Actions |
+
+
+ |
+
+
+
+
+
+
+
diff --git a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts
index d0abc4dd..10dcf561 100644
--- a/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts
+++ b/bookie/src/app/temporal-product/temporal-product-detail/temporal-product-detail.component.ts
@@ -9,13 +9,14 @@ import { MatFormFieldModule } from '@angular/material/form-field';
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 moment from 'moment';
import { MenuCategory } from '../../core/menu-category';
import { SaleCategory } from '../../core/sale-category';
-import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
+import { Product, StockKeepingUnit, TemporalProduct } from '../temporal-product';
import { TemporalProductService } from '../temporal-product.service';
@Component({
@@ -24,14 +25,13 @@ import { TemporalProductService } from '../temporal-product.service';
styleUrls: ['./temporal-product-detail.component.css'],
imports: [
MatButtonModule,
-
MatCheckboxModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatOptionModule,
-
MatSelectModule,
+ MatTableModule,
ReactiveFormsModule,
],
})
@@ -42,72 +42,117 @@ export class TemporalProductDetailComponent implements OnInit, AfterViewInit {
private snackBar = inject(MatSnackBar);
private ser = inject(TemporalProductService);
+ productDisplayedColumns = ['name', 'fractionUnits', 'saleCategory', 'validFrom', 'validTill', 'actions'];
+ skuDisplayedColumns = ['units', 'salePrice', 'menuCategory', 'validFrom', 'validTill', 'actions'];
+
@ViewChild('name', { static: true }) nameElement?: ElementRef;
- form: FormGroup<{
- id: FormControl
;
+
+ private selectedProduct: Product | null;
+ private selectedSku: StockKeepingUnit | null;
+
+ productForm: FormGroup<{
name: FormControl;
- units: FormControl;
- menuCategory: FormControl;
+ fractionUnits: FormControl;
saleCategory: FormControl;
- price: FormControl;
+ validFrom: FormControl;
+ validTill: FormControl;
+ }>;
+
+ skuForm: FormGroup<{
+ units: FormControl;
+ fraction: FormControl;
+ productYield: FormControl;
+ costPrice: FormControl;
+ salePrice: FormControl;
+ menuCategory: FormControl;
hasHappyHour: FormControl;
isNotAvailable: FormControl;
- quantity: FormControl;
validFrom: FormControl;
validTill: FormControl;
}>;
menuCategories: MenuCategory[] = [];
saleCategories: SaleCategory[] = [];
- item: Product = new Product();
+ item: TemporalProduct = new TemporalProduct();
constructor() {
// Create form
- this.form = new FormGroup({
- id: new FormControl('', { nonNullable: true }),
+ this.productForm = new FormGroup({
name: new FormControl('', { nonNullable: true }),
- units: new FormControl('', { nonNullable: true }),
- menuCategory: new FormControl('', { nonNullable: true }),
+ fractionUnits: new FormControl('', { nonNullable: true }),
saleCategory: new FormControl('', { nonNullable: true }),
- price: new FormControl(0, { nonNullable: true }),
+ validFrom: new FormControl(null),
+ validTill: new FormControl(null),
+ });
+ this.skuForm = new FormGroup({
+ units: new FormControl('', { nonNullable: true }),
+ fraction: new FormControl(1, { nonNullable: true }),
+ productYield: new FormControl(1, { nonNullable: true }),
+ costPrice: new FormControl(0, { nonNullable: true }),
+ salePrice: new FormControl(0, { nonNullable: true }),
+
+ menuCategory: new FormControl('', { nonNullable: true }),
hasHappyHour: new FormControl(false, { nonNullable: true }),
isNotAvailable: new FormControl(false, { nonNullable: true }),
- quantity: new FormControl(0, { nonNullable: true }),
- validFrom: new FormControl(new Date()),
- validTill: new FormControl(new Date()),
+ validFrom: new FormControl(null),
+ validTill: new FormControl(null),
});
+ this.selectedProduct = null;
+ this.selectedSku = null;
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as {
- item: Product;
+ item: TemporalProduct;
menuCategories: MenuCategory[];
saleCategories: SaleCategory[];
};
this.menuCategories = data.menuCategories;
this.saleCategories = data.saleCategories;
- this.showItem(data.item);
+ this.item = data.item;
+ // this.showItem(data.item);
});
}
- showItem(item: Product) {
- this.item = item;
- this.form.setValue({
- id: this.item.id ?? '',
- name: this.item.name,
- units: this.item.units,
- menuCategory: this.item.menuCategory?.id ?? '',
- saleCategory: this.item.saleCategory?.id ?? '',
- price: this.item.price,
- hasHappyHour: this.item.hasHappyHour,
- isNotAvailable: this.item.isNotAvailable,
- quantity: this.item.quantity,
- validFrom: this.item.validFrom === null ? null : moment(this.item.validFrom, 'DD-MMM-YYYY').toDate(),
- validTill: this.item.validTill === null ? null : moment(this.item.validTill, 'DD-MMM-YYYY').toDate(),
- });
+ private parseDateOrNull(d: string | null | undefined): Date | null {
+ return !d ? null : moment(d, 'DD-MMM-YYYY').toDate();
}
+ private formatDateOrNull(d: Date | null | undefined): string | null {
+ return !d ? null : moment(d).format('DD-MMM-YYYY');
+ }
+
+ // showItem(item: TemporalProduct) {
+ // this.item = item;
+ // this.selectedProduct = this.item.products[0];
+ // this.selectedSku = this.item.skus[0];
+
+ // this.productForm.setValue({
+ // // product
+ // name: this.selectedProduct.name ?? '',
+ // fractionUnits: this.selectedProduct.fraction_units ?? '',
+ // saleCategory: this.selectedProduct.saleCategory?.id ?? '',
+ // validFrom: this.parseDateOrNull(this.selectedProduct.validFrom),
+ // validTill: this.parseDateOrNull(this.selectedProduct.validTill),
+ // });
+
+ // this.skuForm.setValue({
+ // // sku
+ // units: this.selectedSku.units ?? '',
+ // fraction: this.selectedSku.fraction ?? 1,
+ // productYield: this.selectedSku.productYield ?? 1,
+ // costPrice: this.selectedSku.costPrice ?? 0,
+ // salePrice: this.selectedSku.salePrice ?? 0,
+
+ // menuCategory: this.selectedSku.menuCategory?.id ?? '',
+ // hasHappyHour: this.selectedSku.hasHappyHour ?? false,
+ // isNotAvailable: this.selectedSku.isNotAvailable ?? false,
+ // validFrom: this.parseDateOrNull(this.selectedSku.validFrom),
+ // validTill: this.parseDateOrNull(this.selectedSku.validTill),
+ // });
+ // }
+
ngAfterViewInit() {
setTimeout(() => {
if (this.nameElement !== undefined) {
@@ -116,8 +161,131 @@ export class TemporalProductDetailComponent implements OnInit, AfterViewInit {
}, 0);
}
+ editProduct(p: Product) {
+ this.selectedProduct = p;
+
+ console.log(p);
+
+ this.productForm.setValue({
+ name: p.name ?? '',
+ fractionUnits: p.fraction_units ?? '',
+ saleCategory: p.saleCategory?.id ?? '',
+ validFrom: this.parseDateOrNull(p.validFrom),
+ validTill: this.parseDateOrNull(p.validTill),
+ });
+ setTimeout(() => this.nameElement?.nativeElement?.focus?.(), 0);
+ }
+
+ updateProduct() {
+ if (!this.selectedProduct) return;
+ const formModel = this.productForm.value;
+
+ const p = this.selectedProduct;
+
+ p.name = formModel.name ?? '';
+ p.fraction_units = formModel.fractionUnits ?? '';
+
+ if (!p.saleCategory) p.saleCategory = new SaleCategory();
+ if (p.saleCategory === null || p.saleCategory === undefined) {
+ p.saleCategory = new SaleCategory();
+ }
+
+ p.saleCategory.id = formModel.saleCategory;
+
+ p.validFrom = this.formatDateOrNull(formModel.validFrom);
+ p.validTill = this.formatDateOrNull(formModel.validTill);
+ }
+
+ deleteProduct() {
+ if (!this.selectedProduct) return;
+
+ const idx = (this.item.products ?? []).indexOf(this.selectedProduct);
+ if (idx >= 0) {
+ this.item.products.splice(idx, 1);
+ }
+
+ this.selectedProduct = null;
+
+ // Reset form
+ this.productForm.reset({
+ name: '',
+ fractionUnits: '',
+ saleCategory: '',
+ validFrom: null,
+ validTill: null,
+ });
+ }
+
+ editSku(s: StockKeepingUnit) {
+ this.selectedSku = s;
+
+ this.skuForm.setValue({
+ units: s.units ?? '',
+ fraction: s.fraction ?? 1,
+ productYield: s.productYield ?? 1,
+ costPrice: s.costPrice ?? 0,
+ salePrice: s.salePrice ?? 0,
+ menuCategory: s.menuCategory?.id ?? '',
+
+ hasHappyHour: s.hasHappyHour ?? false,
+ isNotAvailable: s.isNotAvailable ?? false,
+ validFrom: this.parseDateOrNull(s.validFrom),
+ validTill: this.parseDateOrNull(s.validTill),
+ });
+ }
+
+ updateSku() {
+ if (!this.selectedSku) return;
+ const formModel = this.skuForm.value;
+
+ const s = this.selectedSku;
+ s.units = formModel.units ?? '';
+ s.fraction = formModel.fraction ?? 1;
+ s.productYield = formModel.productYield ?? 1;
+ s.costPrice = formModel.costPrice ?? 0;
+ s.salePrice = formModel.salePrice ?? 0;
+
+ if (!s.menuCategory) s.menuCategory = new MenuCategory();
+ if (s.menuCategory === null || s.menuCategory === undefined) {
+ s.menuCategory = new MenuCategory();
+ }
+
+ s.menuCategory.id = formModel.menuCategory;
+
+ s.hasHappyHour = formModel.hasHappyHour ?? false;
+ s.isNotAvailable = formModel.isNotAvailable ?? false;
+
+ s.validFrom = this.formatDateOrNull(formModel.validFrom);
+ s.validTill = this.formatDateOrNull(formModel.validTill);
+ }
+
+ deleteSku() {
+ if (!this.selectedSku) return;
+
+ const idx = (this.item.skus ?? []).indexOf(this.selectedSku);
+ if (idx >= 0) {
+ this.item.skus.splice(idx, 1);
+ }
+
+ this.selectedSku = null;
+
+ // Reset form
+ this.skuForm.reset({
+ units: '',
+ fraction: 1,
+ productYield: 1,
+ costPrice: 0,
+ salePrice: 0,
+ menuCategory: '',
+ hasHappyHour: false,
+ isNotAvailable: false,
+ validFrom: null,
+ validTill: null,
+ });
+ }
+
update() {
- this.ser.update(this.getItem()).subscribe({
+ this.ser.update(this.item).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/temporal-products');
@@ -129,7 +297,7 @@ export class TemporalProductDetailComponent implements OnInit, AfterViewInit {
}
delete() {
- this.ser.delete(this.item.versionId as string).subscribe({
+ this.ser.delete(this.item.products[0].id as string).subscribe({
next: () => {
this.snackBar.open('', 'Success');
this.router.navigateByUrl('/temporal-products');
@@ -152,28 +320,4 @@ export class TemporalProductDetailComponent implements OnInit, AfterViewInit {
}
});
}
-
- getItem(): Product {
- const formModel = this.form.value;
- this.item.id = formModel.id;
- this.item.name = formModel.name ?? '';
- this.item.units = formModel.units ?? '';
- if (this.item.menuCategory === null || this.item.menuCategory === undefined) {
- this.item.menuCategory = new MenuCategory();
- }
- this.item.menuCategory.id = formModel.menuCategory;
- if (this.item.saleCategory === null || this.item.saleCategory === undefined) {
- this.item.saleCategory = new SaleCategory();
- }
- this.item.saleCategory.id = formModel.saleCategory;
- this.item.price = formModel.price ?? 0;
- this.item.hasHappyHour = formModel.hasHappyHour ?? false;
- this.item.isNotAvailable = formModel.isNotAvailable ?? false;
- this.item.quantity = formModel.quantity ?? 0;
- this.item.validFrom = !formModel.validFrom ? null : moment(formModel.validFrom).format('DD-MMM-YYYY');
- console.log(formModel.validTill);
- this.item.validTill = !formModel.validTill ? null : moment(formModel.validTill).format('DD-MMM-YYYY');
-
- return this.item;
- }
}
diff --git a/bookie/src/app/temporal-product/temporal-product-list.resolver.ts b/bookie/src/app/temporal-product/temporal-product-list.resolver.ts
index 5d669cfd..52e43edd 100644
--- a/bookie/src/app/temporal-product/temporal-product-list.resolver.ts
+++ b/bookie/src/app/temporal-product/temporal-product-list.resolver.ts
@@ -1,9 +1,9 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
-import { StockKeepingUnit as Product } from '../core/stock-keeping-unit';
+import { TemporalProduct } from './temporal-product';
import { TemporalProductService } from './temporal-product.service';
-export const temporalProductListResolver: ResolveFn = () => {
+export const temporalProductListResolver: ResolveFn = () => {
return inject(TemporalProductService).list();
};
diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts
index 52e44d1b..7f27c51c 100644
--- a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts
+++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list-datasource.ts
@@ -2,13 +2,11 @@ import { DataSource } from '@angular/cdk/collections';
import { merge, Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
-import { MenuCategory } from '../../core/menu-category';
-import { SaleCategory } from '../../core/sale-category';
-import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit';
+import { TemporalProduct } from '../temporal-product';
-export class TemporalProductListDatasource extends DataSource {
- public data: Product[][];
- public filteredData: Product[][];
+export class TemporalProductListDatasource extends DataSource {
+ public data: TemporalProduct[];
+ public filteredData: TemporalProduct[];
public search: string;
public menuCategory: string;
public saleCategory: string;
@@ -17,7 +15,7 @@ export class TemporalProductListDatasource extends DataSource {
private readonly searchFilter: Observable,
private readonly menuCategoryFilter: Observable,
private readonly saleCategoryFilter: Observable,
- private readonly dataObs: Observable,
+ private readonly dataObs: Observable,
) {
super();
this.data = [];
@@ -27,7 +25,7 @@ export class TemporalProductListDatasource extends DataSource {
this.saleCategory = '';
}
- connect(): Observable {
+ connect(): Observable {
const dataMutations = [
this.dataObs.pipe(
tap((x) => {
@@ -52,42 +50,49 @@ export class TemporalProductListDatasource extends DataSource {
];
return merge(...dataMutations).pipe(
map(() => this.getFilteredData(this.data, this.search, this.menuCategory, this.saleCategory)),
- tap((x: Product[][]) => {
+ tap((x: TemporalProduct[]) => {
this.filteredData = x;
}),
- map((x: Product[][]) => x.reduce((p, c) => p.concat(c), [])),
);
}
disconnect() {}
- private getFilteredData(data: Product[][], search: string, menuCategory: string, saleCategory: string): Product[][] {
- return data.filter(
- (o: Product[]) =>
- o
- .filter(
- (x: Product) =>
- search === null ||
- search === undefined ||
- search === '' ||
- `${x.name} ${x.units} ${x.saleCategory?.name} ${x.menuCategory?.name}`
- .toLowerCase()
- .indexOf(search.toLowerCase()) !== -1,
- )
- .filter(
- (x) =>
- menuCategory === null ||
- menuCategory === undefined ||
- menuCategory === '' ||
- (x.menuCategory as MenuCategory).id === menuCategory,
- )
- .filter(
- (x) =>
- saleCategory === null ||
- saleCategory === undefined ||
- saleCategory === '' ||
- (x.saleCategory as SaleCategory).id === saleCategory,
- ).length > 0,
- );
+ private getFilteredData(
+ data: TemporalProduct[],
+ search: string,
+ menuCategory: string,
+ saleCategory: string,
+ ): TemporalProduct[] {
+ const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean);
+
+ return data.filter((tp: TemporalProduct) => {
+ search = search.toLowerCase();
+
+ const products = tp.products ?? [];
+ const skus = tp.skus ?? [];
+
+ // 1) Search: match ANY product/sku fields
+ const matchesSearch =
+ tokens.length === 0 ||
+ tokens.every(
+ (token) =>
+ products.some((p) => {
+ const hay = `${p.name ?? ''} ${p.fraction_units ?? ''} ${p.saleCategory?.name ?? ''}`.toLowerCase();
+ return hay.includes(token);
+ }) ||
+ skus.some((k) => {
+ const hay = `${k.units ?? ''} ${k.menuCategory?.name ?? ''}`.toLowerCase();
+ return hay.includes(token);
+ }),
+ );
+
+ const matchesMenuCategory = menuCategory === '' || skus.some((k) => (k.menuCategory?.id ?? '') === menuCategory);
+
+ const matchesSaleCategory =
+ saleCategory === '' || products.some((p) => (p.saleCategory?.id ?? '') === saleCategory);
+
+ return matchesSearch && matchesMenuCategory && matchesSaleCategory;
+ });
}
}
diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html
index 9b4376a8..0ade8289 100644
--- a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html
+++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.html
@@ -31,16 +31,36 @@
-
- Name
+
+ Products
+
+
+
+
+ Skus
+
+
+ @for (s of row.skus; track s) {
+ -
+ {{ s.units }}
+
+ }
@@ -48,26 +68,26 @@
Price
- {{ row.price | currency: 'INR' }}
+ {{ row.skus.at(-1).price | currency: 'INR' }}
Menu Category
- {{ row.menuCategory.name }}
+ {{ row.skus.at(-1).menuCategory.name }}
Sale Category
- {{ row.saleCategory.name }}
+ {{ row.products.at(-1).saleCategory.name }}
Details
-
-
+
diff --git a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts
index 8406e53e..cefa36a4 100644
--- a/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts
+++ b/bookie/src/app/temporal-product/temporal-product-list/temporal-product-list.component.ts
@@ -1,4 +1,4 @@
-import { DecimalPipe, CurrencyPipe } from '@angular/common';
+import { CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatOptionModule } from '@angular/material/core';
@@ -13,7 +13,7 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { MenuCategory } from '../../core/menu-category';
import { SaleCategory } from '../../core/sale-category';
-import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit';
+import { TemporalProduct } from '../temporal-product';
import { TemporalProductListDatasource } from './temporal-product-list-datasource';
@Component({
@@ -22,7 +22,7 @@ import { TemporalProductListDatasource } from './temporal-product-list-datasourc
styleUrls: ['./temporal-product-list.component.css'],
imports: [
CurrencyPipe,
- DecimalPipe,
+ // DecimalPipe,
MatFormFieldModule,
MatIconModule,
@@ -40,7 +40,7 @@ export class TemporalProductListComponent implements OnInit {
searchFilter = new Observable();
menuCategoryFilter = new BehaviorSubject('');
saleCategoryFilter = new BehaviorSubject('');
- data: BehaviorSubject = new BehaviorSubject([]);
+ data: BehaviorSubject = new BehaviorSubject([]);
dataSource: TemporalProductListDatasource = new TemporalProductListDatasource(
this.searchFilter,
this.menuCategoryFilter,
@@ -54,11 +54,11 @@ export class TemporalProductListComponent implements OnInit {
saleCategory: FormControl;
}>;
- list: Product[][] = [];
+ list: TemporalProduct[] = [];
menuCategories: MenuCategory[] = [];
saleCategories: SaleCategory[] = [];
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
- displayedColumns: string[] = ['name', 'price', 'menuCategory', 'saleCategory', 'info', 'quantity'];
+ displayedColumns: string[] = ['product', 'sku', 'price', 'menuCategory', 'saleCategory', 'info'];
constructor() {
this.form = new FormGroup({
@@ -66,7 +66,7 @@ export class TemporalProductListComponent implements OnInit {
menuCategory: new FormControl(''),
saleCategory: new FormControl(''),
});
- this.data.subscribe((data: Product[][]) => {
+ this.data.subscribe((data: TemporalProduct[]) => {
this.list = data;
});
this.searchFilter = this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged());
@@ -89,7 +89,7 @@ export class TemporalProductListComponent implements OnInit {
);
this.route.data.subscribe((value) => {
const data = value as {
- list: Product[][];
+ list: TemporalProduct[];
menuCategories: MenuCategory[];
saleCategories: SaleCategory[];
};
@@ -97,7 +97,7 @@ export class TemporalProductListComponent implements OnInit {
});
}
- loadData(list: Product[][], menuCategories: MenuCategory[], saleCategories: SaleCategory[]) {
+ loadData(list: TemporalProduct[], menuCategories: MenuCategory[], saleCategories: SaleCategory[]) {
this.menuCategories = menuCategories;
this.saleCategories = saleCategories;
this.data.next(list);
diff --git a/bookie/src/app/temporal-product/temporal-product.resolver.ts b/bookie/src/app/temporal-product/temporal-product.resolver.ts
index 5c517527..7652b7fa 100644
--- a/bookie/src/app/temporal-product/temporal-product.resolver.ts
+++ b/bookie/src/app/temporal-product/temporal-product.resolver.ts
@@ -1,10 +1,10 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
-import { StockKeepingUnit as Product } from '../core/stock-keeping-unit';
+import { TemporalProduct } from './temporal-product';
import { TemporalProductService } from './temporal-product.service';
-export const temporalProductResolver: ResolveFn = (route) => {
+export const temporalProductResolver: ResolveFn = (route) => {
const id = route.paramMap.get('id');
return inject(TemporalProductService).get(id as string);
};
diff --git a/bookie/src/app/temporal-product/temporal-product.service.ts b/bookie/src/app/temporal-product/temporal-product.service.ts
index 9e2bb589..c20808a9 100644
--- a/bookie/src/app/temporal-product/temporal-product.service.ts
+++ b/bookie/src/app/temporal-product/temporal-product.service.ts
@@ -4,7 +4,7 @@ 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 { TemporalProduct } from './temporal-product';
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
@@ -18,27 +18,27 @@ export class TemporalProductService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
- get(id: string): Observable {
+ get(id: string): Observable {
return this.http
- .get(`${url}/${id}`)
- .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable;
+ .get(`${url}/${id}`)
+ .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable;
}
- list(): Observable {
+ list(): Observable {
return this.http
- .get(`${url}/list`)
- .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable;
+ .get(`${url}/list`)
+ .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable;
}
- update(product: Product): Observable {
+ update(product: TemporalProduct): Observable {
return this.http
- .put(`${url}/${product.versionId}`, product, httpOptions)
+ .put(`${url}/${product.products[0].id}`, product, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable;
}
delete(id: string): Observable {
return this.http
- .delete(`${url}/${id}`, httpOptions)
+ .delete(`${url}/${id}`, httpOptions)
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable;
}
}
diff --git a/bookie/src/app/temporal-product/temporal-product.ts b/bookie/src/app/temporal-product/temporal-product.ts
new file mode 100644
index 00000000..d26f1771
--- /dev/null
+++ b/bookie/src/app/temporal-product/temporal-product.ts
@@ -0,0 +1,68 @@
+import { MenuCategory } from '../core/menu-category';
+import { SaleCategory } from '../core/sale-category';
+
+export class Product {
+ id: string | undefined;
+ versionId?: string;
+ name: string;
+ fraction_units: string;
+ saleCategory?: SaleCategory;
+ sortOrder: number;
+
+ validFrom: string | null;
+ validTill: string | null;
+
+ public constructor(init?: Partial) {
+ this.id = undefined;
+ this.name = '';
+ this.fraction_units = '';
+ this.sortOrder = 0;
+ this.validFrom = null;
+ this.validTill = null;
+ Object.assign(this, init);
+ }
+}
+
+export class StockKeepingUnit {
+ id: string | undefined;
+ versionId?: string;
+ units: string;
+ fraction: number;
+ productYield: number;
+ costPrice: number;
+ salePrice: number;
+ menuCategory?: MenuCategory;
+
+ sortOrder: number;
+
+ hasHappyHour: boolean;
+ isNotAvailable: boolean;
+
+ validFrom: string | null;
+ validTill: string | null;
+
+ public constructor(init?: Partial) {
+ this.units = '';
+ this.fraction = 1;
+ this.productYield = 1;
+ this.costPrice = 0;
+ this.salePrice = 0;
+ this.sortOrder = 0;
+ this.hasHappyHour = false;
+ this.isNotAvailable = false;
+ this.validFrom = null;
+ this.validTill = null;
+ Object.assign(this, init);
+ }
+}
+
+export class TemporalProduct {
+ products: Product[];
+ skus: StockKeepingUnit[];
+
+ public constructor(init?: Partial) {
+ this.products = [];
+ this.skus = [];
+ Object.assign(this, init);
+ }
+}