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 ID - - -
-
- - Name - - - - Units - - -
-
- - Price - - - - Quantity - - -
-
- Has Happy Hour? - Is Not Available? -
-
- - Menu Category - - @for (mc of menuCategories; track mc) { - - {{ mc.name }} - - } - - - - Sale Category - - @for (sc of saleCategories; track sc) { - - {{ sc.name }} - - } - - -
-
- - Valid From - - - - - - Valid Till - - - - -
-
+
+
+

Product Versions

+
+
+ + Name + + + + Fraction Units + + +
+
+ + Sale Category + + @for (sc of saleCategories; track sc) { + + {{ sc.name }} + + } + + +
+
+ + Valid From + + + + + + Valid Till + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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 + + + + + Fraction + + +
+ +
+ + Yield + + + + + Cost Price + + + + + Sale Price + + +
+
+ + Menu Category + + @for (mc of menuCategories; track mc) { + + {{ mc.name }} + + } + + +
+
+ Has Happy Hour? + Is Not Available? +
+
+ + Valid From + + + + + + Valid Till + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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); + } +}