diff --git a/barker/alembic/versions/367ecf7b898f_bundles.py b/barker/alembic/versions/367ecf7b898f_bundles.py index 58cbe8ff..fc767238 100644 --- a/barker/alembic/versions/367ecf7b898f_bundles.py +++ b/barker/alembic/versions/367ecf7b898f_bundles.py @@ -54,7 +54,7 @@ def upgrade(): op.create_unique_constraint( op.f("uq_inventories_kot_id"), "inventories", - ["kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"], + ["kot_id", "sku_id", "is_happy_hour", "type", "parent_id"], postgresql_nulls_not_distinct=True, ) op.create_foreign_key( diff --git a/barker/barker/models/inventory.py b/barker/barker/models/inventory.py index 979ac1b5..d5d9e999 100644 --- a/barker/barker/models/inventory.py +++ b/barker/barker/models/inventory.py @@ -22,9 +22,8 @@ from sqlalchemy import ( from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship -from barker.models.inventory_type import InventoryType - from ..db.base_class import reg +from .inventory_type import InventoryType from .tax import Tax @@ -37,7 +36,7 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class Inventory: __tablename__ = "inventories" - __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"),) + __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "type", "parent_id"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) kot_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("kots.id"), nullable=False, index=True) @@ -64,20 +63,26 @@ class Inventory: def __init__( self, - kot_id: uuid.UUID, - sku_id: uuid.UUID | None, - quantity: Decimal, - price: Decimal, - discount: Decimal, - is_hh: bool, - tax_id: uuid.UUID | None, - tax_rate: Decimal, - sort_order: int, + kot_id: uuid.UUID | None = None, + kot: Kot | None = None, + sku_id: uuid.UUID | None = None, + quantity: Decimal = Decimal(0), + price: Decimal = Decimal(0), + discount: Decimal = Decimal(0), + is_hh: bool = False, + tax_id: uuid.UUID | None = None, + tax_rate: Decimal = Decimal(0), + sort_order: int = 0, type_: InventoryType = InventoryType.regular, + parent_id: uuid.UUID | None = None, + parent: Inventory | None = None, sku: StockKeepingUnit | None = None, tax: Tax | None = None, ): - self.kot_id = kot_id + if kot_id is not None: + self.kot_id = kot_id + if kot is not None: + self.kot = kot if sku_id is not None: self.sku_id = sku_id self.quantity = quantity @@ -89,6 +94,10 @@ class Inventory: self.tax_rate = tax_rate self.sort_order = sort_order self.type_ = type_ + if parent_id is not None: + self.parent_id = parent_id + if parent is not None: + self.parent = parent if sku is not None: self.sku = sku if tax is not None: diff --git a/barker/barker/models/voucher.py b/barker/barker/models/voucher.py index 2f8994ee..0a6b8008 100644 --- a/barker/barker/models/voucher.py +++ b/barker/barker/models/voucher.py @@ -91,4 +91,4 @@ class Voucher: @property def amount(self) -> Decimal: - return round(sum([i.amount for k in self.kots for i in k.inventories], Decimal(0)), 2) + return round(sum([i.amount for k in self.kots for i in k.inventories if i.parent_id is None], Decimal(0)), 2) diff --git a/barker/barker/routers/__init__.py b/barker/barker/routers/__init__.py index 26803f94..5c235006 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -1,13 +1,14 @@ from datetime import UTC, date, datetime, timedelta +from typing import Any -from sqlalchemy import and_, or_ - -from barker.models.product import Product -from barker.models.product_version import ProductVersion -from barker.models.sku_version import SkuVersion -from barker.models.stock_keeping_unit import StockKeepingUnit +from sqlalchemy import Label, and_, or_ from ..core.config import settings +from ..models.bundle_item import BundleItem +from ..models.product import Product +from ..models.product_version import ProductVersion +from ..models.sku_version import SkuVersion +from ..models.stock_keeping_unit import StockKeepingUnit def query_date(d: str | None = None) -> date: @@ -55,7 +56,14 @@ def _sv_active(date_: date): ) -def _pv_onclause(date_: date): +def _bundle_active(date_: date): + return and_( + or_(BundleItem.valid_from == None, BundleItem.valid_from <= date_), # noqa: E711 + or_(BundleItem.valid_till == None, BundleItem.valid_till >= date_), # noqa: E711 + ) + + +def _pv_onclause(date_: date | Label[Any]): return and_( ProductVersion.product_id == Product.id, or_( @@ -69,7 +77,7 @@ def _pv_onclause(date_: date): ) -def _sv_onclause(date_: date): +def _sv_onclause(date_: date | Label[Any]): return and_( SkuVersion.sku_id == StockKeepingUnit.id, or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index e458175f..726a5528 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -1,5 +1,6 @@ import uuid +from collections.abc import Sequence from datetime import date, timedelta from decimal import Decimal @@ -12,6 +13,7 @@ from sqlalchemy.sql.functions import count, func from ..core.config import settings from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture +from ..models.bundle_item import BundleItem from ..models.inventory import Inventory from ..models.kot import Kot from ..models.menu_category import MenuCategory @@ -25,7 +27,7 @@ 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.product_query import ProductQuery +from ..schemas.product_query import BundleItemQuery, ProductQuery from ..schemas.sale_category import SaleCategoryLink from ..schemas.tax import TaxLink from ..schemas.user_token import UserToken @@ -432,6 +434,32 @@ def product_list(date_: date, db: Session) -> list[schemas.Product]: # ] +def bundle_items(sku_id: uuid.UUID, date_: date, db: Session) -> Sequence[BundleItem]: + return ( + db.execute( + select(BundleItem) + .join(BundleItem.item) + .join(SkuVersion, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .where( + BundleItem.bundle_id == sku_id, + or_(BundleItem.valid_from == None, BundleItem.valid_from <= date_), # noqa: E711 + or_(BundleItem.valid_till == None, BundleItem.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItem.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItem.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) + ) + .unique() + .scalars() + .all() + ) + + @router.get("/query", response_model=list[ProductQuery]) def show_term( q: str | None = None, @@ -478,46 +506,18 @@ def show_term( query = query.where(or_(ProductVersion.name.ilike(f"%{name}%"), SkuVersion.units.ilike(f"%{name}%"))) with SessionFuture() as db: - for item in db.execute(query).unique().scalars().all(): - list_.append(query_product_info(item, False)) - if mc is not None and item.has_happy_hour: - list_.append(query_product_info(item, True)) + for sv in db.execute(query).unique().scalars().all(): + bi: Sequence[BundleItem] = [] + if sv.sku.is_bundle: + bi = bundle_items(sv.sku_id, date_, db) + info = query_product_info(sv, False, bi) + list_.append(info) + if mc is not None and sv.has_happy_hour: + info = query_product_info(sv, True, bi) + list_.append(info) return list_ -# def product_list_of_sale_category(date_: date, db: Session) -> list[schemas.Product]: -# return [ -# product_info(item) -# for item in db.execute( -# select(ProductVersion) -# .join(ProductVersion.menu_category) -# .join(ProductVersion.sale_category) -# .where( -# and_( -# or_( -# ProductVersion.valid_from == None, # noqa: E711 -# ProductVersion.valid_from <= date_, -# ), -# or_( -# ProductVersion.valid_till == None, # noqa: E711 -# ProductVersion.valid_till >= date_, -# ), -# ) -# ) -# .order_by(MenuCategory.sort_order) -# .order_by(MenuCategory.name) -# .order_by(ProductVersion.sort_order) -# .order_by(ProductVersion.name) -# .options( -# contains_eager(ProductVersion.menu_category), -# contains_eager(ProductVersion.sale_category), -# ) -# ) -# .scalars() -# .all() -# ] - - @router.get("/{id_}", response_model=schemas.Product) def show_id( id_: uuid.UUID, @@ -564,10 +564,15 @@ def show_id( return product_info(version) -def query_product_info(item: SkuVersion, happy_hour: bool) -> ProductQuery: +def query_product_info(item: SkuVersion, happy_hour: bool, bundle_items: Sequence[BundleItem]) -> ProductQuery: return ProductQuery( id_=item.sku_id, name=("H H " if happy_hour else "") + f"{item.sku.product.versions[0].name} ({item.units})", + menu_category=MenuCategoryLink( + id_=item.menu_category.id, + name=item.menu_category.name, + skus=[], + ), sale_category=SaleCategoryLink( id_=item.sku.product.versions[0].sale_category_id, name=item.sku.product.versions[0].sale_category.name, @@ -581,6 +586,21 @@ def query_product_info(item: SkuVersion, happy_hour: bool) -> ProductQuery: has_happy_hour=happy_hour, is_not_available=item.sku.is_not_available, sort_order=item.sku.sort_order, + is_bundle=item.sku.is_bundle, + bundle_items=[ + BundleItemQuery( + id_=bi.item_id, + name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})", + price=bi.sale_price, + quantity=bi.quantity, + tax=TaxLink( + id_=bi.item.product.versions[0].sale_category.tax.id, + name=bi.item.product.versions[0].sale_category.tax.name, + rate=bi.item.product.versions[0].sale_category.tax.rate, + ), + ) + for bi in bundle_items + ], ) diff --git a/barker/barker/routers/voucher/__init__.py b/barker/barker/routers/voucher/__init__.py index b41c5dbe..7acbab3a 100644 --- a/barker/barker/routers/voucher/__init__.py +++ b/barker/barker/routers/voucher/__init__.py @@ -4,18 +4,24 @@ from datetime import datetime from decimal import Decimal from fastapi import HTTPException, status -from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy import func, select +from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql import expression from ...models.bill import Bill from ...models.guest_book import GuestBook from ...models.guest_book_type import GuestBookType +from ...models.inventory import Inventory +from ...models.kot import Kot from ...models.overview import Overview from ...models.overview_status import OverviewStatus +from ...models.product import Product +from ...models.product_version import ProductVersion from ...models.regime import Regime from ...models.settle_option import SettleOption from ...models.settlement import Settlement +from ...models.sku_version import SkuVersion +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas import voucher as schemas @@ -180,3 +186,69 @@ def happy_hour_items_more_than_regular(kots: list[schemas.Kot]) -> bool: else: inventories[inventory.sku.id_]["normal"] += inventory.quantity return any(value["happy"] > value["normal"] for value in inventories.values()) + + +def get_voucher(voucher_id: uuid.UUID, db: Session, product_version_onclause, sku_version_onclause) -> Voucher: + item: Voucher = ( + db.execute( + select(Voucher) + .join(Voucher.food_table) + .join(Voucher.customer, isouter=True) + .join(Voucher.kots) + .join(Kot.inventories) + .join(Inventory.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .where(Voucher.id == voucher_id) + .order_by(Kot.code, Inventory.sort_order) + .options( + contains_eager(Voucher.food_table), + contains_eager(Voucher.customer), + contains_eager(Voucher.kots) + .contains_eager(Kot.inventories) + .contains_eager(Inventory.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + contains_eager(Voucher.kots) + .contains_eager(Kot.inventories) + .contains_eager(Inventory.sku) + .contains_eager(StockKeepingUnit.versions), + ) + ) + .unique() + .scalar_one() + ) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Voucher not found", + ) + return item + + +def get_sku( + sku_id: uuid.UUID, + db: Session, + product_version_onclause, + sku_version_onclause, +) -> StockKeepingUnit: + sku: StockKeepingUnit = ( + db.execute( + select(StockKeepingUnit) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.sale_category) + .where(StockKeepingUnit.id == sku_id) + .options( + contains_eager(StockKeepingUnit.versions), + contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), + ) + ) + .unique() + .scalar_one() + ) + return sku diff --git a/barker/barker/routers/voucher/save.py b/barker/barker/routers/voucher/save.py index 79472ed9..8bae387c 100644 --- a/barker/barker/routers/voucher/save.py +++ b/barker/barker/routers/voucher/save.py @@ -5,21 +5,19 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import and_, func, or_, select +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, contains_eager +from sqlalchemy.orm import Session from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture +from ...models.bundle_item import BundleItem from ...models.guest_book import GuestBook from ...models.inventory import Inventory from ...models.inventory_modifier import InventoryModifier +from ...models.inventory_type import InventoryType from ...models.kot import Kot -from ...models.product import Product -from ...models.product_version import ProductVersion -from ...models.sku_version import SkuVersion -from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.bill import print_bill @@ -30,12 +28,14 @@ from ...routers.voucher import ( do_update_settlements, do_update_table, get_guest_book, + get_sku, get_tax, happy_hour_has_discount, happy_hour_items_balanced, ) from ...schemas import voucher as schemas from ...schemas.user_token import UserToken +from .. import _bundle_active, _pv_onclause, _sv_onclause router = APIRouter() @@ -82,27 +82,13 @@ 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 - ) + product_version_onclause = _pv_onclause(product_date) + sku_version_onclause = _sv_onclause(product_date) check_permissions(None, voucher_type, user.permissions) kot_id = db.execute(select(func.coalesce(func.max(Voucher.kot_id), 0) + 1)).scalar_one() - item = Voucher( + voucher = Voucher( now, guest_book.pax if guest_book is not None else data.pax, kot_id, @@ -111,86 +97,127 @@ def do_save( voucher_type, user.id_, ) - db.add(item) + db.add(voucher) for dk in data.kots: # Filter out nil inventories dk.inventories = [dki for dki in dk.inventories if round(dki.quantity, 2) != 0] # Filter out nil kots data.kots = [k for k in data.kots if len(k.inventories) > 0] - for k in data.kots: - if not happy_hour_items_balanced(k.inventories): + for data_kot in data.kots: + if not happy_hour_items_balanced(data_kot.inventories): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Happy hour products are not balanced.", ) - if happy_hour_has_discount(k.inventories): + if happy_hour_has_discount(data_kot.inventories): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Discount is not allowed on happy hour products.", ) code = db.execute(select(func.coalesce(func.max(Kot.code), 0) + 1)).scalar_one() - kot = Kot(item.id, code, item.food_table_id, item.date, item.user_id) - item.kots.append(kot) + kot = Kot(voucher.id, code, voucher.food_table_id, voucher.date, voucher.user_id) + voucher.kots.append(kot) db.add(kot) - for index, i in enumerate(k.inventories): - if round(i.quantity, 2) == 0: + index = 0 + for data_inv in data_kot.inventories: + index += 1 + if round(data_inv.quantity, 2) == 0: continue total_quantity: Decimal = round( - Decimal(sum(inv.quantity for ko in data.kots for inv in ko.inventories if inv.sku.id_ == i.sku.id_)), + Decimal( + sum(inv.quantity for ko in data.kots for inv in ko.inventories if inv.sku.id_ == data_inv.sku.id_) + ), 2, ) - sku: StockKeepingUnit = ( - db.execute( - select(StockKeepingUnit) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .join(ProductVersion.sale_category) - .where(StockKeepingUnit.id == i.sku.id_) - .options( - contains_eager(StockKeepingUnit.versions), - contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions) - .contains_eager(ProductVersion.sale_category), - ) - ) - .unique() - .scalar_one() - ) + sku = get_sku(data_inv.sku.id_, db, product_version_onclause, sku_version_onclause) if total_quantity < 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Quantity of {sku.product.versions[0].name} ({sku.versions[0].units}) cannot be less than 0", ) - if round(i.quantity, 2) < 0 and "edit-printed-product" not in user.permissions: + if round(data_inv.quantity, 2) < 0 and "edit-printed-product" not in user.permissions: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not allowed to delete printed products.\n In this case {sku.product.versions[0].name} ({sku.versions[0].units})", ) + if data_inv.type_ == InventoryType.bundle_item: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items cannot be added directly. Please add the bundle parent item.", + ) + if data_inv.type_ == InventoryType.bundle and len(data_inv.children) == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items must have children items.", + ) tax_rate = get_tax(sku.product.versions[0].sale_category.tax.rate, voucher_type) inv = Inventory( kot_id=kot.id, sku_id=sku.id, - quantity=round(i.quantity, 2), + quantity=round(data_inv.quantity, 2), price=sku.versions[0].sale_price, - discount=round(min(i.discount, sku.product.versions[0].sale_category.discount_limit), 5), - is_hh=i.is_happy_hour, + discount=round(min(data_inv.discount, sku.product.versions[0].sale_category.discount_limit), 5), + is_hh=data_inv.is_happy_hour, tax_id=sku.product.versions[0].sale_category.tax_id, tax_rate=tax_rate, sort_order=index, + type_=data_inv.type_, ) kot.inventories.append(inv) db.add(inv) - for m in i.modifiers: + for m in data_inv.modifiers: mod = InventoryModifier(None, m.id_, Decimal(0)) inv.modifiers.append(mod) db.add(mod) + if data_inv.type_ == InventoryType.bundle: + if data_inv.price != sum(child.price * child.quantity for child in data_inv.children): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle price for {data_inv.sku.name} must be equal to sum of child item prices.", + ) + for child in data_inv.children: + index += 1 + sku_child = get_sku(child.sku.id_, db, product_version_onclause, sku_version_onclause) + tax_rate_child = get_tax(sku_child.product.versions[0].sale_category.tax.rate, voucher_type) + bundle = db.execute( + select(BundleItem).where( + BundleItem.bundle_id == data_inv.sku.id_, + BundleItem.item_id == child.sku.id_, + _bundle_active(product_date), + ) + ).scalar_one_or_none() + if not bundle: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle item {child.sku.name} is not part of bundle {data_inv.sku.name}", + ) + inv_child = Inventory( + kot=kot, + sku_id=sku_child.id, + quantity=round(data_inv.quantity * bundle.quantity, 2), + price=bundle.sale_price, # TODO: Fix + discount=round( + min(data_inv.discount, sku_child.product.versions[0].sale_category.discount_limit), 5 + ), + is_hh=data_inv.is_happy_hour, + tax_id=sku_child.product.versions[0].sale_category.tax_id, + tax_rate=tax_rate_child, + sort_order=index, + type_=InventoryType.bundle_item, + parent=inv, + ) + inv.children.append(inv_child) + db.add(inv_child) + for m in child.modifiers: + mod = InventoryModifier(None, m.id_, Decimal(0)) + inv_child.modifiers.append(mod) + db.add(mod) db.flush() - do_update_settlements(item, [], db) - do_update_bill_numbers(item, db) + do_update_settlements(voucher, [], db) + do_update_bill_numbers(voucher, db) if ( - sum(len(k.inventories) for k in item.kots) == 0 + sum(len(k.inventories) for k in voucher.kots) == 0 and guest_book is None and data.pax == 0 and data.customer is None @@ -199,4 +226,4 @@ def do_save( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Please add some products", ) - return item + return voucher diff --git a/barker/barker/routers/voucher/show.py b/barker/barker/routers/voucher/show.py index 93976e09..2095f617 100644 --- a/barker/barker/routers/voucher/show.py +++ b/barker/barker/routers/voucher/show.py @@ -4,9 +4,11 @@ import uuid from datetime import timedelta from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import Date, and_, func, or_, select +from sqlalchemy import Date, func, select from sqlalchemy.orm import Session, contains_eager +from barker.models.inventory_type import InventoryType + from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture @@ -42,6 +44,8 @@ from ...schemas.voucher_out import ( from ...schemas.voucher_out import ( Kot as KotOut, ) +from .. import _pv_onclause, _sv_onclause +from . import get_voucher router = APIRouter() @@ -49,59 +53,16 @@ router = APIRouter() @router.get("/from-id/{id_}") def from_id( - id_: str, + id_: uuid.UUID, user: UserToken = Security(get_user), ) -> VoucherOut: with SessionFuture() as db: day = func.cast( - Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) - item: Voucher = ( - db.execute( - select(Voucher) - .join(Voucher.food_table) - .join(Voucher.customer, isouter=True) - .join(Voucher.kots) - .join(Kot.inventories) - .join(Inventory.sku) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .where(Voucher.id == id_) - .order_by(Kot.code) - .options( - contains_eager(Voucher.food_table), - contains_eager(Voucher.customer), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.versions), - ) - ) - .unique() - .scalar_one() - ) + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) + item = get_voucher(id_, db, product_version_onclause, sku_version_onclause) return voucher_info(item, db) @@ -112,24 +73,10 @@ def from_bill( ) -> VoucherOut: with SessionFuture() as db: day = func.cast( - Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) match = re.compile(r"^(\w+)-(\d+)$").match(id_) if not match or len(match.groups()) != 2: raise HTTPException( @@ -185,24 +132,10 @@ def from_table( ) -> VoucherOut | VoucherBlank: with SessionFuture() as db: day = func.cast( - Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) guest = None if g is None else db.execute(select(GuestBook).where(GuestBook.id == g)).scalar_one() if v is not None: overview = ( @@ -221,47 +154,22 @@ def from_table( detail="Voucher not found", ) else: - item = ( - db.execute( - select(Voucher) - .join(Voucher.food_table) - .join(Voucher.customer, isouter=True) - .join(Voucher.kots) - .join(Kot.inventories) - .join(Inventory.sku) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .where(Voucher.id == v) - .order_by(Kot.code) - .options( - contains_eager(Voucher.food_table), - contains_eager(Voucher.customer), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.versions), - ) - ) - .unique() - .scalar_one_or_none() - ) + item = get_voucher(v, db, product_version_onclause, sku_version_onclause) if item is None: - item = db.execute( - select(Voucher) - .join(Voucher.food_table) - .join(Voucher.customer, isouter=True) - .where(Voucher.id == v) - .options( - contains_eager(Voucher.food_table), - contains_eager(Voucher.customer), + item = ( + db.execute( + select(Voucher) + .join(Voucher.food_table) + .join(Voucher.customer, isouter=True) + .where(Voucher.id == v) + .options( + contains_eager(Voucher.food_table), + contains_eager(Voucher.customer), + ) ) - ).scalar_one() + .unique() + .scalar_one() + ) if guest is not None: item.customer = guest.customer @@ -271,7 +179,7 @@ def from_table( def voucher_info(item: Voucher, db: Session) -> VoucherOut: - return VoucherOut( + voucher_out = VoucherOut( id_=item.id, date_=item.date, date_tip=item.date, @@ -288,51 +196,64 @@ def voucher_info(item: Voucher, db: Session) -> VoucherOut: narration=item.narration, reason=item.reason, voucher_type=item.voucher_type, - kots=[ - KotOut( - id_=k.id, - code=k.code, - date_=k.date, - user=UserLink(id_=k.user_id, name=k.user.name), - inventories=[ - InventoryOut( - id_=i.id, - sort_order=i.sort_order, - product=InventoryProduct( - id_=i.sku_id, - name=("H H " if i.is_happy_hour else "") - + f"{i.sku.product.versions[0].name} ({i.sku.versions[0].units})", - menu_category=ProductMenuCategory( - id_=i.sku.versions[0].menu_category_id, - name=i.sku.versions[0].menu_category.name, - ), - sale_category=ProductSaleCategory( - id_=i.sku.product.versions[0].sale_category_id, - name=i.sku.product.versions[0].sale_category.name, - discount_limit=i.sku.product.versions[0].sale_category.discount_limit, - ), - ), - quantity=i.quantity, - price=i.price, - is_happy_hour=i.is_happy_hour, - tax_rate=i.tax_rate, - tax=TaxLink(id_=i.tax_id, name=i.tax.name), - discount=i.discount, - modifiers=[ - ModifierLink( - id_=m.modifier.id, - name=m.modifier.name, - price=m.price, - ) - for m in i.modifiers - ], - ) - for i in k.inventories - ], - ) - for k in item.kots - ], + kots=[], ) + for k in item.kots: + kot = KotOut( + id_=k.id, + code=k.code, + date_=k.date, + user=UserLink(id_=k.user_id, name=k.user.name), + inventories=[], + ) + voucher_out.kots.append(kot) + for i in k.inventories: + inventory = InventoryOut( + id_=i.id, + sort_order=i.sort_order, + sku=InventoryProduct( + id_=i.sku_id, + name=("H H " if i.is_happy_hour else "") + + f"{i.sku.product.versions[0].name} ({i.sku.versions[0].units})", + menu_category=ProductMenuCategory( + id_=i.sku.versions[0].menu_category_id, + name=i.sku.versions[0].menu_category.name, + ), + sale_category=ProductSaleCategory( + id_=i.sku.product.versions[0].sale_category_id, + name=i.sku.product.versions[0].sale_category.name, + discount_limit=i.sku.product.versions[0].sale_category.discount_limit, + ), + is_bundle=i.sku.is_bundle, + ), + quantity=i.quantity, + price=i.price, + is_happy_hour=i.is_happy_hour, + tax_rate=i.tax_rate, + tax=TaxLink(id_=i.tax_id, name=i.tax.name), + discount=i.discount, + type_=i.type_, + modifiers=[ + ModifierLink( + id_=m.modifier.id, + name=m.modifier.name, + price=m.price, + ) + for m in i.modifiers + ], + children=[], + ) + if i.parent_id and inventory.type_ != InventoryType.bundle_item: + raise Exception("Inventory type and parent ID mismatch") + if i.parent_id is not None and inventory.type_ == InventoryType.bundle_item: + parent = next((inv for inv in kot.inventories if inv.id_ == i.parent_id), None) + if parent is not None: + parent.children.append(inventory) + else: + raise Exception("Parent inventory not found") + else: + kot.inventories.append(inventory) + return voucher_out def voucher_blank(table: FoodTable, guest: GuestBook | None) -> VoucherBlank: diff --git a/barker/barker/routers/voucher/split.py b/barker/barker/routers/voucher/split.py index 9dc0f4ef..806a045c 100644 --- a/barker/barker/routers/voucher/split.py +++ b/barker/barker/routers/voucher/split.py @@ -1,18 +1,22 @@ import uuid from collections import defaultdict -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import delete, func, select +from sqlalchemy import Date, delete, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from barker.routers import _pv_onclause, _sv_onclause + +from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture from ...models.inventory import Inventory from ...models.inventory_modifier import InventoryModifier +from ...models.inventory_type import InventoryType from ...models.kot import Kot from ...models.overview import Overview from ...models.settle_option import SettleOption @@ -23,6 +27,7 @@ from ...routers.voucher import ( do_update_bill_numbers, do_update_settlements, do_update_table, + get_voucher, ) from ...schemas import split as schemas from ...schemas.receive_payment import ReceivePaymentItem as SettleSchema @@ -43,7 +48,12 @@ def split( with SessionFuture() as db: now = datetime.now(UTC).replace(tzinfo=None) update_table = u - item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + day = func.cast( + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) + item: Voucher = get_voucher(id_, db, product_version_onclause, sku_version_onclause) original_voucher_type = item.voucher_type item.voucher_type = VoucherType.VOID item.reason = "Bill Split" @@ -53,8 +63,18 @@ def split( db.execute(delete(Overview).where(Overview.voucher_id == item.id)) check_permissions(None, item.voucher_type, user.permissions) - one_inventories = [i for k in item.kots for i in k.inventories if i.id in data.inventories] - two_inventories = [i for k in item.kots for i in k.inventories if i.id not in data.inventories] + one_inventories = [ + i + for k in item.kots + for i in k.inventories + if i.id in data.inventories or i.parent_id in data.inventories + ] + two_inventories = [ + i + for k in item.kots + for i in k.inventories + if i.id not in data.inventories and i.parent_id not in data.inventories + ] one = save( one_inventories, @@ -128,7 +148,9 @@ def save( item.kots.append(kot) db.add(kot) db.flush() + parent_map: dict[uuid.UUID, Inventory] = {} for old_inventory in split_inventories: + parent = parent_map.get(old_inventory.parent_id) if old_inventory.parent_id else None inv = Inventory( kot_id=kot.id, sku_id=old_inventory.sku_id, @@ -139,13 +161,20 @@ def save( tax_id=old_inventory.tax_id, tax_rate=old_inventory.tax_rate, sort_order=old_inventory.sort_order, + type_=old_inventory.type_, + parent=parent, ) - kot.inventories.append(inv) + if not parent: + kot.inventories.append(inv) + else: + parent.children.append(inv) db.add(inv) for m in old_inventory.modifiers: mod = InventoryModifier(None, m.modifier_id, m.price) inv.modifiers.append(mod) db.add(mod) + if old_inventory.type_ == InventoryType.bundle: + parent_map[old_inventory.id] = inv db.flush() do_update_bill_numbers(item, db) do_update_settlements(item, [], db) diff --git a/barker/barker/routers/voucher/update.py b/barker/barker/routers/voucher/update.py index 67bd2d78..76d8de9f 100644 --- a/barker/barker/routers/voucher/update.py +++ b/barker/barker/routers/voucher/update.py @@ -4,31 +4,34 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import and_, func, or_, select +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import contains_eager +from sqlalchemy.orm import Session, contains_eager from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture +from ...models.bundle_item import BundleItem from ...models.inventory import Inventory from ...models.inventory_modifier import InventoryModifier +from ...models.inventory_type import InventoryType from ...models.kot import Kot from ...models.product import Product from ...models.product_version import ProductVersion -from ...models.sku_version import SkuVersion from ...models.stock_keeping_unit import StockKeepingUnit -from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.bill import print_bill from ...printing.kot import print_kot +from ...routers import _bundle_active, _pv_onclause, _sv_onclause from ...routers.voucher import ( check_permissions, do_update_bill_numbers, do_update_settlements, do_update_table, get_guest_book, + get_sku, get_tax, + get_voucher, happy_hour_has_discount, happy_hour_items_balanced, happy_hour_items_more_than_regular, @@ -40,6 +43,26 @@ from ...schemas.user_token import UserToken router = APIRouter() +def discount_limit(sku_id: uuid.UUID, db: Session, product_version_onclause) -> Decimal: + sku: StockKeepingUnit = ( + db.execute( + select(StockKeepingUnit) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.sale_category) + .where(StockKeepingUnit.id == sku_id) + .options( + contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), + ) + ) + .unique() + .scalar_one() + ) + return sku.product.versions[0].sale_category.discount_limit + + @router.put("/update/{id_}") def update_route( id_: uuid.UUID, @@ -55,65 +78,27 @@ 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 - ) + product_version_onclause = _pv_onclause(product_date) + sku_version_onclause = _sv_onclause(product_date) update_table = u voucher_type = VoucherType(p) guest_book = get_guest_book(g, db) need_to_print_kot = False - item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + # item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + item = get_voucher(id_, db, product_version_onclause, sku_version_onclause) check_permissions(item, voucher_type, user.permissions) - - item.pax = data.pax - if data.customer is not None: - item.customer_id = data.customer.id_ - else: - item.customer_id = None - if guest_book is not None: - item.pax = guest_book.pax - item.customer_id = guest_book.customer_id - item.food_table_id = data.table.id_ - if item.voucher_type != VoucherType.KOT: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal error, reprints should not reach here", ) - if item.voucher_type == VoucherType.KOT and voucher_type != VoucherType.KOT: - item.date = now - item.voucher_type = voucher_type - item.user_id = user.id_ - item.last_edit_date = now if happy_hour_items_more_than_regular(data.kots): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="When product has happy hours\n" "Minimum same number of regular items also needed in the whole bill.", ) - for ik in item.kots: - for iki in ik.inventories: - iki.tax_rate = get_tax(iki.tax_rate, voucher_type) - # TODO: Need to check from the database product for the max discount - # However simple relationship does not work as we need the product validity as well - # Still we should not fret too much as we are checking this in the frontend. - iki.discount = next( - round(inv.discount, 5) for ko in data.kots for inv in ko.inventories if inv.id_ == iki.id - ) for dk in data.kots: # Filter out nil inventories dk.inventories = [dki for dki in dk.inventories if round(dki.quantity, 2) != 0] @@ -130,27 +115,48 @@ def update_route( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Happy hour products are not balanced.", ) - for nk in data.kots: - if nk.id_ is not None: + + item.pax = data.pax + if data.customer is not None: + item.customer_id = data.customer.id_ + else: + item.customer_id = None + if guest_book is not None: + item.pax = guest_book.pax + item.customer_id = guest_book.customer_id + item.food_table_id = data.table.id_ + + if item.voucher_type == VoucherType.KOT and voucher_type != VoucherType.KOT: + item.date = now + item.voucher_type = voucher_type + item.user_id = user.id_ + item.last_edit_date = now + flat_inventories: list[schemas.Inventory] = [] + for d_kot in data.kots: + for d_inv in d_kot.inventories: + if d_inv.type_ != InventoryType.bundle_item: + flat_inventories.append(d_inv) + if len(d_inv.children) > 0: + for d_child in d_inv.children: + flat_inventories.append(d_child) + for ik in item.kots: + for iki in ik.inventories: + iki.tax_rate = get_tax(iki.tax_rate, voucher_type) + new_discount = next(round(inv.discount, 5) for inv in flat_inventories if inv.id_ == iki.id) + iki.discount = round(min(new_discount, discount_limit(iki.sku_id, db, product_version_onclause)), 5) + for data_kot in data.kots: + if data_kot.id_ is not None: continue need_to_print_kot = True code = db.execute(select(func.coalesce(func.max(Kot.code), 0) + 1)).scalar_one() kot = Kot(item.id, code, item.food_table_id, now, item.user_id) item.kots.append(kot) db.add(kot) - for index, nki in enumerate(nk.inventories): - sku: StockKeepingUnit = db.execute( - select(StockKeepingUnit) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .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), - ) - ).scalar_one() - if round(nki.quantity, 2) < 0: + index = 0 + for data_inv in data_kot.inventories: + index += 1 + sku = get_sku(data_inv.sku.id_, db, product_version_onclause, sku_version_onclause) + if round(data_inv.quantity, 2) < 0: if "edit-printed-product" not in user.permissions: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -169,30 +175,84 @@ def update_route( * -1, 2, ) - if round(nki.quantity, 2) < minimum: + if round(data_inv.quantity, 2) < minimum: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Quantity of {sku.product.versions[0].name} ({sku.versions[0].units}) cannot be less than {minimum}", ) + if data_inv.type_ == InventoryType.bundle_item: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items cannot be added directly. Please add the bundle parent item.", + ) + if data_inv.type_ == InventoryType.bundle and len(data_inv.children) == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items must have children items.", + ) tax_rate = get_tax(sku.product.versions[0].sale_category.tax.rate, voucher_type) inv = Inventory( - kot_id=kot.id, + kot=kot, sku_id=sku.id, - quantity=round(nki.quantity, 2), + quantity=round(data_inv.quantity, 2), price=sku.versions[0].sale_price, - discount=round(min(nki.discount, sku.product.versions[0].sale_category.discount_limit), 5), - is_hh=nki.is_happy_hour, + discount=round(min(data_inv.discount, sku.product.versions[0].sale_category.discount_limit), 5), + is_hh=data_inv.is_happy_hour, tax_rate=tax_rate, sort_order=index, tax_id=sku.product.versions[0].sale_category.tax_id, - tax=sku.product.versions[0].sale_category.tax, + type_=data_inv.type_, ) kot.inventories.append(inv) db.add(inv) - for m in nki.modifiers: + for m in data_inv.modifiers: mod = InventoryModifier(None, m.id_, Decimal(0)) inv.modifiers.append(mod) db.add(mod) + if data_inv.type_ == InventoryType.bundle: + if data_inv.price != sum(child.price * child.quantity for child in data_inv.children): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle price for {data_inv.sku.name} must be equal to sum of child item prices.", + ) + for child in data_inv.children: + index += 1 + sku_child = get_sku(child.sku.id_, db, product_version_onclause, sku_version_onclause) + tax_rate_child = get_tax(sku_child.product.versions[0].sale_category.tax.rate, voucher_type) + bundle = db.execute( + select(BundleItem).where( + BundleItem.bundle_id == data_inv.sku.id_, + BundleItem.item_id == child.sku.id_, + _bundle_active(product_date), + ) + ).scalar_one_or_none() + if not bundle: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle item {child.sku.name} is not part of bundle {data_inv.sku.name}", + ) + inv_child = Inventory( + kot=kot, + sku_id=sku_child.id, + quantity=round(data_inv.quantity * bundle.quantity, 2), + price=bundle.sale_price, # TODO: Fix + discount=round( + min(data_inv.discount, sku_child.product.versions[0].sale_category.discount_limit), + 5, + ), + is_hh=data_inv.is_happy_hour, + tax_id=sku_child.product.versions[0].sale_category.tax_id, + tax_rate=tax_rate_child, + sort_order=index, + type_=InventoryType.bundle_item, + parent=inv, + ) + inv.children.append(inv_child) + db.add(inv_child) + for m in child.modifiers: + mod = InventoryModifier(None, m.id_, Decimal(0)) + inv_child.modifiers.append(mod) + db.add(mod) do_update_bill_numbers(item, db) do_update_settlements(item, [], db) if update_table: diff --git a/barker/barker/schemas/beer_consumption_report.py b/barker/barker/schemas/beer_consumption_report.py index e37af9f1..41c16473 100644 --- a/barker/barker/schemas/beer_consumption_report.py +++ b/barker/barker/schemas/beer_consumption_report.py @@ -1,6 +1,6 @@ from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer @@ -22,7 +22,7 @@ class BeerConsumptionReportItem(BaseModel): return value.strftime("%d-%b-%Y") # dynamic fields (e.g., regular, nc, kot) - dynamic_amounts: dict[str, Daf] = Field(default_factory=dict) + dynamic_amounts: Annotated[dict[str, Daf], Field(default_factory=dict)] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py index 6d81e4cc..7d214425 100644 --- a/barker/barker/schemas/bundle.py +++ b/barker/barker/schemas/bundle.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -10,23 +11,23 @@ from .menu_category import MenuCategoryLink class BundleItem(BaseModel): id_: uuid.UUID | None = None - name: str = Field(..., min_length=1) - item_id: uuid.UUID = Field(..., description="StockKeepingUnit ID of the item") - sale_price: Daf = Field(ge=Decimal(0)) - quantity: Daf = Field(gt=Decimal(0)) + name: Annotated[str, Field(min_length=1)] + item_id: Annotated[uuid.UUID, Field(description="StockKeepingUnit ID of the item")] + sale_price: Annotated[Daf, Field(ge=Decimal(0))] + quantity: Annotated[Daf, Field(gt=Decimal(0))] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) class BundleIn(BaseModel): - name: str = Field(..., min_length=1) - units: str = Field(..., min_length=1) - sale_price: Daf = Field(ge=Decimal(0)) + name: Annotated[str, Field(min_length=1)] + units: Annotated[str, Field(min_length=1)] + sale_price: Annotated[Daf, Field(ge=Decimal(0))] has_happy_hour: bool is_not_available: bool sort_order: int - menu_category: MenuCategoryLink = Field(...) + menu_category: MenuCategoryLink items: list[BundleItem] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/customer.py b/barker/barker/schemas/customer.py index 89539422..35c02478 100644 --- a/barker/barker/schemas/customer.py +++ b/barker/barker/schemas/customer.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -10,14 +11,14 @@ from . import Daf, to_camel class CustomerDiscount(BaseModel): id_: uuid.UUID name: str - discount: Daf = Field(ge=Decimal(0), default=Decimal(0), le=Decimal(1)) - limit: Daf = Field(ge=Decimal(0), default=Decimal(0), le=Decimal(1)) + discount: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0), le=Decimal(1))] + limit: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0), le=Decimal(1))] model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class CustomerIn(BaseModel): - name: str = Field(..., min_length=1) - # phone: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] + # phone: Annotated[str, Field(min_length=1) phone: str address: str | None = None print_in_bill: bool @@ -32,7 +33,7 @@ class Customer(CustomerIn): class CustomerLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/device.py b/barker/barker/schemas/device.py index 1a299abc..da5b0828 100644 --- a/barker/barker/schemas/device.py +++ b/barker/barker/schemas/device.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer @@ -10,7 +10,7 @@ from .section import SectionLink class DeviceIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] enabled: bool section: SectionLink model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -33,6 +33,6 @@ class Device(DeviceIn): class DeviceLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/guest_book.py b/barker/barker/schemas/guest_book.py index 7827364b..0d66df71 100644 --- a/barker/barker/schemas/guest_book.py +++ b/barker/barker/schemas/guest_book.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Annotated, Any from pydantic import ( BaseModel, @@ -20,7 +20,7 @@ class GuestBookIn(BaseModel): name: str phone: str address: str | None = None - pax: int = Field(ge=0) + pax: Annotated[int, Field(ge=0)] booking_date: datetime | None = None arrival_date: datetime | None = None notes: str | None = None diff --git a/barker/barker/schemas/master.py b/barker/barker/schemas/master.py index 766d9b79..f40541d4 100644 --- a/barker/barker/schemas/master.py +++ b/barker/barker/schemas/master.py @@ -1,12 +1,14 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel class AccountBase(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] is_starred: bool is_active: bool model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/menu_category.py b/barker/barker/schemas/menu_category.py index 041a7f51..1900ce17 100644 --- a/barker/barker/schemas/menu_category.py +++ b/barker/barker/schemas/menu_category.py @@ -1,5 +1,7 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel @@ -7,7 +9,7 @@ from .product_link import ProductLink class MenuCategoryIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] is_active: bool is_fixture: bool model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -25,7 +27,7 @@ class MenuCategoryBlank(MenuCategoryIn): class MenuCategoryLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None enabled: bool | None = None skus: list[ProductLink] diff --git a/barker/barker/schemas/modifier.py b/barker/barker/schemas/modifier.py index 582e12a2..32536a41 100644 --- a/barker/barker/schemas/modifier.py +++ b/barker/barker/schemas/modifier.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -9,9 +10,9 @@ from .modifier_category import ModifierCategoryLink class ModifierIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] show_in_bill: bool - price: Daf = Field(ge=Decimal(0), default=Decimal(0)) + price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] is_active: bool modifier_category: ModifierCategoryLink model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -22,7 +23,7 @@ class Modifier(ModifierIn): class ModifierLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None price: Daf | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/modifier_category.py b/barker/barker/schemas/modifier_category.py index 76739dfc..089c7030 100644 --- a/barker/barker/schemas/modifier_category.py +++ b/barker/barker/schemas/modifier_category.py @@ -1,5 +1,7 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel @@ -7,9 +9,9 @@ from .menu_category import MenuCategoryLink class ModifierCategoryIn(BaseModel): - name: str = Field(..., min_length=1) - minimum: int = Field(ge=0) - maximum: int | None = Field(ge=0) + name: Annotated[str, Field(min_length=1)] + minimum: Annotated[int, Field(ge=0)] + maximum: Annotated[int | None, Field(ge=0)] is_active: bool menu_categories: list[MenuCategoryLink] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -27,6 +29,6 @@ class ModifierCategoryBlank(ModifierCategoryIn): class ModifierCategoryLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/modifier_category_for_product.py b/barker/barker/schemas/modifier_category_for_product.py index 02d3b472..27be893d 100644 --- a/barker/barker/schemas/modifier_category_for_product.py +++ b/barker/barker/schemas/modifier_category_for_product.py @@ -1,5 +1,7 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel @@ -8,9 +10,9 @@ from .modifier import ModifierLink class ModifierCategoryForProduct(BaseModel): id_: uuid.UUID - name: str = Field(..., min_length=1) - minimum: int = Field(ge=0) - maximum: int | None = Field(ge=0) + name: Annotated[str, Field(min_length=1)] + minimum: Annotated[int, Field(ge=0)] + maximum: Annotated[int | None, Field(ge=0)] is_active: bool modifiers: list[ModifierLink] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/printer.py b/barker/barker/schemas/printer.py index cf8c445c..b92cf4ed 100644 --- a/barker/barker/schemas/printer.py +++ b/barker/barker/schemas/printer.py @@ -1,13 +1,15 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel class PrinterIn(BaseModel): - name: str = Field(..., min_length=1) - address: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] + address: Annotated[str, Field(min_length=1)] cut_code: str model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -24,5 +26,5 @@ class PrinterBlank(PrinterIn): class PrinterLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/product.py b/barker/barker/schemas/product.py index d0cf3eff..325d83fe 100644 --- a/barker/barker/schemas/product.py +++ b/barker/barker/schemas/product.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator @@ -11,9 +11,9 @@ from .stock_keeping_unit import StockKeepingUnit class ProductIn(BaseModel): - name: str = Field(..., min_length=1) - fraction_units: str = Field(..., min_length=1) - sale_category: SaleCategoryLink = Field(...) + name: Annotated[str, Field(min_length=1)] + fraction_units: Annotated[str, Field(min_length=1)] + sale_category: SaleCategoryLink sort_order: int skus: list[StockKeepingUnit] diff --git a/barker/barker/schemas/product_link.py b/barker/barker/schemas/product_link.py index bcbdcd64..a65bcdff 100644 --- a/barker/barker/schemas/product_link.py +++ b/barker/barker/schemas/product_link.py @@ -1,12 +1,12 @@ import uuid -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from . import to_camel class ProductLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None enabled: bool | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/product_query.py b/barker/barker/schemas/product_query.py index 09f3aaf8..589edc18 100644 --- a/barker/barker/schemas/product_query.py +++ b/barker/barker/schemas/product_query.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -10,23 +11,30 @@ from .sale_category import SaleCategoryLink from .tax import TaxLink +class BundleItemQuery(BaseModel): + id_: uuid.UUID | None = None + name: Annotated[str, Field()] + price: Annotated[Daf, Field()] + quantity: Annotated[Daf, Field()] + tax: TaxLink | None = None + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + class ProductQuery(BaseModel): - # one row per SKU version - id_: uuid.UUID = Field(..., description="SkuVersion ID") - # product_id: uuid.UUID = Field(..., description="Product ID") - - name: str = Field(..., min_length=1) - - # units: str = Field(..., min_length=1) - price: Daf = Field(ge=Decimal(0)) + id_: Annotated[uuid.UUID, Field(description="SkuVersion ID")] + name: Annotated[str, Field(min_length=1)] + price: Annotated[Daf, Field(ge=Decimal(0))] has_happy_hour: bool is_not_available: bool - - sort_order: int = Field(ge=0, default=0) + is_bundle: bool + sort_order: Annotated[int, Field(ge=0, default=0)] menu_category: MenuCategoryLink | None = None sale_category: SaleCategoryLink | None = None tax: TaxLink | None = None + bundle_items: list[BundleItemQuery] = [] + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/product_sale_report.py b/barker/barker/schemas/product_sale_report.py index 03034f0c..e13473b5 100644 --- a/barker/barker/schemas/product_sale_report.py +++ b/barker/barker/schemas/product_sale_report.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer @@ -16,7 +16,7 @@ class ProductSaleReportItem(BaseModel): is_happy_hour: bool # dynamic fields (e.g., regular, nc, kot) - dynamic_amounts: dict[str, Daf] = Field(default_factory=dict) + dynamic_amounts: Annotated[dict[str, Daf], Field(default_factory=dict)] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/regime.py b/barker/barker/schemas/regime.py index c7dfbc98..3ae43fe9 100644 --- a/barker/barker/schemas/regime.py +++ b/barker/barker/schemas/regime.py @@ -1,12 +1,14 @@ +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel class RegimeIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] header: str - prefix: str = Field(..., min_length=1) + prefix: Annotated[str, Field(min_length=1)] is_fixture: bool model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -24,6 +26,6 @@ class RegimeBlank(RegimeIn): class RegimeLink(BaseModel): - id_: int = Field(...) + id_: int name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/role.py b/barker/barker/schemas/role.py index f4886322..caa8ad67 100644 --- a/barker/barker/schemas/role.py +++ b/barker/barker/schemas/role.py @@ -1,5 +1,7 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel @@ -7,7 +9,7 @@ from .permission import PermissionItem class RoleIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] permissions: list[PermissionItem] model_config = ConfigDict(str_strip_whitespace=True) diff --git a/barker/barker/schemas/sale_category.py b/barker/barker/schemas/sale_category.py index cdafafb8..a055eaff 100644 --- a/barker/barker/schemas/sale_category.py +++ b/barker/barker/schemas/sale_category.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -9,9 +10,9 @@ from .tax import TaxLink class SaleCategoryIn(BaseModel): - name: str = Field(..., min_length=1) - discount_limit: Daf = Field(ge=Decimal(0), default=Decimal(0), le=Decimal(1)) - tax: TaxLink = Field(...) + name: Annotated[str, Field(min_length=1)] + discount_limit: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0), le=Decimal(1))] + tax: TaxLink model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -35,6 +36,6 @@ class SaleCategoryForDiscount(BaseModel): class SaleCategoryLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/section.py b/barker/barker/schemas/section.py index 1a72a5c4..1c19712c 100644 --- a/barker/barker/schemas/section.py +++ b/barker/barker/schemas/section.py @@ -1,12 +1,14 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel class SectionIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] model_config = ConfigDict(str_strip_whitespace=True) @@ -21,6 +23,6 @@ class SectionBlank(BaseModel): class SectionLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/section_printer.py b/barker/barker/schemas/section_printer.py index e0a90a11..772c073f 100644 --- a/barker/barker/schemas/section_printer.py +++ b/barker/barker/schemas/section_printer.py @@ -1,3 +1,5 @@ +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel @@ -8,5 +10,5 @@ from .sale_category import SaleCategoryLink class SectionPrinter(BaseModel): sale_category: SaleCategoryLink | None = None printer: PrinterLink | None = None - copies: int = Field(ge=0) + copies: Annotated[int, Field(ge=0)] model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/stock_keeping_unit.py b/barker/barker/schemas/stock_keeping_unit.py index cd2483b4..d3a3099a 100644 --- a/barker/barker/schemas/stock_keeping_unit.py +++ b/barker/barker/schemas/stock_keeping_unit.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator @@ -13,13 +13,13 @@ from .menu_category import MenuCategoryLink class StockKeepingUnit(BaseModel): id_: uuid.UUID | None = None version_id: uuid.UUID | None = 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) + units: Annotated[str, Field(min_length=1)] + fraction: Annotated[Daf, Field(ge=Decimal(1), default=Decimal(1))] + product_yield: Annotated[Daf, Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))] + cost_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + sale_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + menu_category: MenuCategoryLink + sort_order: Annotated[int, Field(ge=0, default=0)] has_happy_hour: bool is_not_available: bool diff --git a/barker/barker/schemas/table.py b/barker/barker/schemas/table.py index e2dcdcbe..0f04f871 100644 --- a/barker/barker/schemas/table.py +++ b/barker/barker/schemas/table.py @@ -1,5 +1,7 @@ import uuid +from typing import Annotated + from pydantic import BaseModel, ConfigDict, Field from . import to_camel @@ -7,8 +9,8 @@ from .section import SectionLink class TableIn(BaseModel): - name: str = Field(..., min_length=1) - seats: int = Field(ge=0) + name: Annotated[str, Field(min_length=1)] + seats: Annotated[int, Field(ge=0)] section: SectionLink is_active: bool sort_order: int @@ -29,6 +31,6 @@ class TableBlank(BaseModel): class TableLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/tax.py b/barker/barker/schemas/tax.py index 7cea67ae..526f5543 100644 --- a/barker/barker/schemas/tax.py +++ b/barker/barker/schemas/tax.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -9,9 +10,9 @@ from .regime import RegimeLink class TaxIn(BaseModel): - name: str = Field(..., min_length=1) - rate: Daf = Field(ge=Decimal(0), default=Decimal(0)) - regime: RegimeLink = Field(...) + name: Annotated[str, Field(min_length=1)] + rate: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + regime: RegimeLink is_fixture: bool model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -23,13 +24,13 @@ class Tax(TaxIn): class TaxBlank(BaseModel): name: str - rate: Daf = Field(ge=Decimal(0), default=Decimal(0)) + rate: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] is_fixture: bool model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) class TaxLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None rate: Daf | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/temporal_product.py b/barker/barker/schemas/temporal_product.py index a8ad9f4b..eb801d9d 100644 --- a/barker/barker/schemas/temporal_product.py +++ b/barker/barker/schemas/temporal_product.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator @@ -14,9 +14,9 @@ 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(...) + name: Annotated[str, Field(min_length=1)] + fraction_units: Annotated[str, Field(min_length=1)] + sale_category: SaleCategoryLink sort_order: int valid_from: date | None = None @@ -53,13 +53,13 @@ class Product(BaseModel): class StockKeepingUnit(BaseModel): id_: uuid.UUID version_id: uuid.UUID - 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) + units: Annotated[str, Field(min_length=1)] + fraction: Annotated[Daf, Field(ge=Decimal(1), default=Decimal(1))] + product_yield: Annotated[Daf, Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))] + cost_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + sale_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + menu_category: MenuCategoryLink + sort_order: Annotated[int, Field(ge=0, default=0)] has_happy_hour: bool is_not_available: bool @@ -96,6 +96,6 @@ class StockKeepingUnit(BaseModel): class TemporalProduct(BaseModel): - products: list[Product] = Field(...) - skus: list[StockKeepingUnit] = Field(...) + products: list[Product] + skus: list[StockKeepingUnit] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/voucher.py b/barker/barker/schemas/voucher.py index 9c317956..05690220 100644 --- a/barker/barker/schemas/voucher.py +++ b/barker/barker/schemas/voucher.py @@ -1,9 +1,12 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from barker.models.inventory_type import InventoryType + from . import Daf, to_camel from .customer import CustomerLink from .modifier import ModifierLink @@ -16,12 +19,15 @@ class Inventory(BaseModel): id_: uuid.UUID | None = None sku: ProductLink quantity: Daf - price: Daf | None = None + price: Daf + is_happy_hour: bool + type_: InventoryType + parent_id: uuid.UUID | None = None tax: TaxLink | None = None tax_rate: Daf | None = None - discount: Daf = Field(ge=0, le=1) - is_happy_hour: bool + discount: Annotated[Daf, Field(ge=0, le=1)] modifiers: list[ModifierLink] + children: Annotated[list["Inventory"], Field(default_factory=list)] amount: Daf | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -55,7 +61,7 @@ class Inventory(BaseModel): @model_validator(mode="after") def calculate_amount(self) -> "Inventory": - price = 0 if self.is_happy_hour else self.price + price = Decimal(0) if self.is_happy_hour else (self.price or Decimal(0)) self.amount = round( Decimal(price * self.quantity * (1 - self.discount) * (1 + (self.tax_rate or Decimal(0)))), 2, @@ -63,6 +69,9 @@ class Inventory(BaseModel): return self +Inventory.model_rebuild() + + class Kot(BaseModel): id_: uuid.UUID | None = None inventories: list[Inventory] diff --git a/barker/barker/schemas/voucher_out.py b/barker/barker/schemas/voucher_out.py index 95d74b9f..0b029493 100644 --- a/barker/barker/schemas/voucher_out.py +++ b/barker/barker/schemas/voucher_out.py @@ -2,10 +2,12 @@ import uuid from datetime import datetime, timedelta from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator from ..core.config import settings +from ..models.inventory_type import InventoryType from . import Daf, to_camel from .customer import CustomerLink from .modifier import ModifierLink @@ -15,21 +17,22 @@ from .user import UserLink class ProductSaleCategory(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None discount_limit: Daf model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class ProductMenuCategory(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class InventoryProduct(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None + is_bundle: bool menu_category: ProductMenuCategory | None = None sale_category: ProductSaleCategory | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -38,13 +41,16 @@ class InventoryProduct(BaseModel): class Inventory(BaseModel): id_: uuid.UUID | None = None sort_order: int | None = None - product: InventoryProduct + sku: InventoryProduct quantity: Daf price: Daf | None = None tax: TaxLink | None = None tax_rate: Daf | None = None - discount: Daf = Field(ge=0, le=1) + discount: Annotated[Daf, Field(ge=0, le=1)] is_happy_hour: bool + type_: InventoryType + parent_id: uuid.UUID | None = None + children: Annotated[list["Inventory"], Field(default_factory=list)] modifiers: list[ModifierLink] amount: Daf | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -92,6 +98,9 @@ class Inventory(BaseModel): return self +Inventory.model_rebuild() + + class Kot(BaseModel): id_: uuid.UUID | None = None code: int | None = None diff --git a/bookie/src/app/core/bill-view-item.ts b/bookie/src/app/core/bill-view-item.ts deleted file mode 100644 index 72571d0d..00000000 --- a/bookie/src/app/core/bill-view-item.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Modifier } from './modifier'; - -export class BillViewItem { - id: string | undefined; - kotId: string | undefined; - isKot: boolean; - info: string; - - skuId: string; - isHappyHour: boolean; - isPrinted: boolean; - quantity: number; - modifiers: Modifier[]; - - public get isOldKot(): boolean { - return this.isKot && this.kotId !== undefined; - } - - public get isNewKot(): boolean { - return this.isKot && this.kotId === undefined; - } - - public constructor(init?: Partial) { - this.isKot = true; - this.info = ''; - this.kotId = ''; - this.skuId = ''; - this.isHappyHour = false; - this.isPrinted = false; - this.quantity = 0; - this.modifiers = []; - Object.assign(this, init); - } -} diff --git a/bookie/src/app/core/product-query.ts b/bookie/src/app/core/product-query.ts index dd65bd1b..effb7400 100644 --- a/bookie/src/app/core/product-query.ts +++ b/bookie/src/app/core/product-query.ts @@ -2,17 +2,35 @@ import { MenuCategory } from './menu-category'; import { SaleCategory } from './sale-category'; import { Tax } from './tax'; +export class BundleItemQuery { + id: string; + name: string; + price: number; + quantity: number; + tax: Tax; + + public constructor(init?: Partial) { + this.id = ''; + this.name = ''; + this.price = 0; + this.quantity = 1; + this.tax = new Tax(); + Object.assign(this, init); + } +} + export class ProductQuery { id: string | undefined; name: string; price: number; hasHappyHour: boolean; isNotAvailable: boolean; + isBundle: boolean; sortOrder: number; menuCategory?: MenuCategory; saleCategory?: SaleCategory; - tax: Tax; + bundleItems: BundleItemQuery[]; public constructor(init?: Partial) { this.id = undefined; @@ -20,8 +38,10 @@ export class ProductQuery { this.price = 0; this.hasHappyHour = false; this.isNotAvailable = false; + this.isBundle = false; this.sortOrder = 0; this.tax = new Tax(); + this.bundleItems = []; Object.assign(this, init); } } diff --git a/bookie/src/app/sales/bill.service.ts b/bookie/src/app/sales/bill.service.ts index 9c728a53..2c78bbb6 100644 --- a/bookie/src/app/sales/bill.service.ts +++ b/bookie/src/app/sales/bill.service.ts @@ -3,19 +3,19 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BehaviorSubject, throwError, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; -import { BillViewItem } from '../core/bill-view-item'; import { ModifierCategory } from '../core/modifier-category'; +import { ProductQuery } from '../core/product-query'; import { ReceivePaymentItem } from '../core/receive-payment-item'; import { SaleCategory } from '../core/sale-category'; import { Table } from '../core/table'; import { ModifierCategoryService } from '../modifier-categories/modifier-category.service'; import { MathService } from '../shared/math.service'; import { Bill } from './bills/bill'; +import { BillRow } from './bills/bill-row'; import { Inventory } from './bills/inventory'; import { Kot } from './bills/kot'; -import { ProductLink } from './bills/product-link'; import { VoucherType } from './bills/voucher-type'; import { VoucherService } from './bills/voucher.service'; import { ModifiersComponent } from './modifiers/modifiers.component'; @@ -30,66 +30,97 @@ export class BillService { private ser = inject(VoucherService); private modifierCategoryService = inject(ModifierCategoryService); - public dataObs: BehaviorSubject; + public dataObs: BehaviorSubject; public bill: Bill = new Bill(); private originalBill: Bill = new Bill(); - public grossAmount: BehaviorSubject; - public discountAmount: BehaviorSubject; - public hhAmount: BehaviorSubject; - public taxAmount: BehaviorSubject; + public grossAmount: Observable; + public discountAmount: Observable; + public hhAmount: Observable; + public taxAmount: Observable; public amount: Observable; public amountVal: number; public selection = new SelectionModel(true, []); - private amountBs: BehaviorSubject; private updateTable: boolean; private allowDeactivate: boolean; // To disable Deactivate Guard on navigation after printing bill or kot. constructor() { - this.dataObs = new BehaviorSubject([]); - this.grossAmount = new BehaviorSubject(0); - this.discountAmount = new BehaviorSubject(0); - this.hhAmount = new BehaviorSubject(0); - this.taxAmount = new BehaviorSubject(0); - this.amountBs = new BehaviorSubject(0); + this.dataObs = new BehaviorSubject([]); this.amountVal = 0; this.updateTable = true; this.allowDeactivate = false; - this.amount = this.amountBs.pipe(tap((x) => (this.amountVal = x))); + + this.grossAmount = this.dataObs.pipe( + map((kots: Kot[]) => + this.math.halfRoundEven( + kots.reduce((t, k) => k.inventories.reduce((a, c) => a + c.price * c.quantity, 0) + t, 0), + ), + ), + ); + + this.hhAmount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? c.price : 0) * c.quantity, 0) + t, + 0, + ), + ); + }), + ); + + this.discountAmount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => + k.inventories.reduce((a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * c.discount, 0) + t, + 0, + ), + ); + }), + ); + + this.taxAmount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => + k.inventories.reduce( + (a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * c.taxRate, + 0, + ) + t, + 0, + ), + ); + }), + ); + + this.amount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => + k.inventories.reduce( + (a, c) => + a + + this.math.halfRoundEven( + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate), + 2, + ), + 0, + ) + t, + 0, + ), + ); + }), + ); } displayBill(): void { this.allowDeactivate = false; - const data = this.transformBillToView(this.bill); - this.dataObs.next(data); - this.updateAmounts(); - } - - transformBillToView(bill: Bill): BillViewItem[] { - return bill.kots - .map((k: Kot) => [ - new BillViewItem({ - kotId: k.id, - isKot: true, - info: k.id ? `Kot: ${k.code} / ${k.date} (${k.user.name})` : '== New Kot ==', - }), - ...k.inventories.map( - (i) => - new BillViewItem({ - id: i.id, - kotId: k.id, - isKot: false, - skuId: i.sku.id, - isHappyHour: i.isHappyHour, - isPrinted: !!k.id, - info: `${i.sku.name} @ ${i.price} - ${this.math.halfRoundEven(i.discount * 100, 2)}%`, - quantity: i.quantity, - modifiers: i.modifiers, - }), - ), - ]) - .reduce((a, c) => a.concat(c), []); + this.dataObs.next(this.bill.kots); } loadData(bill: Bill, updateTable: boolean): void { @@ -104,23 +135,29 @@ export class BillService { minimum(skuId: string, happyHour: boolean): number { return this.bill.kots.reduce( (t, k) => - k.inventories.reduce((a, c) => (c.sku.id === skuId && c.isHappyHour === happyHour ? a + c.quantity : a), 0) + t, + k.inventories + .filter((i) => i.sku.id === skuId && i.isHappyHour === happyHour) + .reduce((a, c) => a + c.quantity, 0) + t, 0, ); } - addSku(sku: ProductLink, quantity: number, discount: number): void { + addOneSku(sku: ProductQuery): void { + const quantity = 1; + const discount = 0; const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find((x) => x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour); - if (quantity < 0) { - const minimum = this.minimum(sku.id as string, sku.hasHappyHour) + quantity; - if (minimum + quantity < 0) { - this.snackBar.open('Total quantity cannot be negative!', 'Error'); - return; - } - } + const old = newKot.inventories.find( + (x) => + x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour && x.type === (sku.isBundle ? 'bundle' : 'regular'), + ); if (old !== undefined) { old.quantity += quantity; + if (old.children) { + old.children.forEach((child) => { + // child. + child.quantity += quantity * (old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0); + }); + } } else { const item = new Inventory({ sku, @@ -131,7 +168,24 @@ export class BillService { tax: sku.tax, discount, modifiers: [], + type: sku.isBundle ? 'bundle' : 'regular', }); + if (sku.isBundle) { + for (const bi of sku.bundleItems) { + const childItem = new Inventory({ + sku: new ProductQuery(bi), + quantity: quantity * bi.quantity, + price: bi.price, + isHappyHour: sku.hasHappyHour, + taxRate: bi.tax?.rate, + tax: bi.tax, + discount: 0, + modifiers: [], + type: 'bundle_item', + }); + item.children.push(childItem); + } + } newKot.inventories.push(item); this.modifierCategoryService.listForSku(sku.id as string).subscribe((result) => { if (result.reduce((a: number, c: ModifierCategory) => a + c.minimum, 0)) { @@ -165,51 +219,56 @@ export class BillService { }); } - addOne(item: BillViewItem): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + addOne(item: BillRow): void { + const old = item.inv as Inventory; old.quantity += 1; + if (old.children) { + old.children.forEach((child) => { + child.quantity += old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0; + }); + } this.displayBill(); } - quantity(item: BillViewItem, quantity: number): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + quantity(item: BillRow, quantity: number): void { + const old = item.inv as Inventory; old.quantity = quantity; + if (old.children) { + old.children.forEach((child) => { + child.quantity = (old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0) * quantity; + }); + } this.displayBill(); } - subtractOne(item: BillViewItem, canEdit: boolean): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; - if (item.quantity >= 1 || (canEdit && this.minimum(item.skuId as string, item.isHappyHour) >= 1)) { + subtractOne(item: BillRow, canEdit: boolean): void { + const newKot = item.kot as Kot; + const old = item.inv as Inventory; + if ( + old.quantity >= 1 || + (canEdit && this.minimum(item.inv?.sku.id as string, item.inv?.isHappyHour || false) >= 1) + ) { old.quantity -= 1; - } else if (item.quantity === 0) { + if (old.children) { + old.children.forEach((child) => { + child.quantity -= old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0; + }); + } + } else if (old.quantity === 0) { newKot.inventories.splice(newKot.inventories.indexOf(old), 1); } this.displayBill(); } - removeItem(item: BillViewItem): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + removeItem(item: BillRow): void { + const newKot = item.kot as Kot; + const old = item.inv as Inventory; newKot.inventories.splice(newKot.inventories.indexOf(old), 1); this.displayBill(); } - modifier(item: BillViewItem): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + modifier(item: BillRow): void { + const old = item.inv as Inventory; this.showModifier(old); } @@ -217,11 +276,14 @@ export class BillService { for (const kot of this.bill.kots) { const noDiscount = kot.inventories.filter((x) => x.isHappyHour).map((x) => x.sku.id as string); for (const inventory of kot.inventories) { - const e = discounts.find((d) => d.id === (inventory.sku.saleCategory as SaleCategory).id); - if (e === undefined || noDiscount.indexOf(inventory.sku.id as string) !== -1) { + if (noDiscount.indexOf(inventory.sku.id as string) !== -1) { continue; + // No discount on happy hour items + } + const d = discounts.find((d) => d.id === (inventory.sku.saleCategory as SaleCategory).id); + if (d) { + inventory.discount = d.discount; } - inventory.discount = e.discount; } } this.displayBill(); @@ -251,7 +313,7 @@ export class BillService { if (!this.isBillDiffent(item, guestBookId)) { return throwError(() => Error('Cannot print a blank KOT\nPlease add some products!')); } - if (!this.happyHourItemsBalanced() || this.happyHourItemsMoreThanRegular()) { + if (!this.happyHourItemsBalanced() || !this.regularItemsMoreThanHappyHour()) { return throwError(() => Error('Happy hour products are not balanced.')); } return this.ser @@ -265,7 +327,7 @@ export class BillService { if (skus === 0) { return throwError(() => Error('Cannot print a blank Bill\nPlease add some products!')); } - if (!this.happyHourItemsBalanced() || this.happyHourItemsMoreThanRegular()) { + if (!this.happyHourItemsBalanced() || !this.regularItemsMoreThanHappyHour()) { return throwError(() => Error('Happy hour products are not balanced.')); } return this.ser @@ -297,59 +359,6 @@ export class BillService { return this.ser.cancelBill(this.bill.id as string, reason, this.updateTable); } - updateAmounts() { - this.grossAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce((t, k) => k.inventories.reduce((a, c) => a + c.price * c.quantity, 0) + t, 0), - ), - ); - this.hhAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? c.price : 0) * c.quantity, 0) + t, - 0, - ), - ), - ); - this.discountAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * c.discount, 0) + t, - 0, - ), - ), - ); - this.taxAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => - k.inventories.reduce( - (a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * c.taxRate, - 0, - ) + t, - 0, - ), - ), - ); - this.amountBs.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => - k.inventories.reduce( - (a, c) => - a + - this.math.halfRoundEven( - (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate), - 2, - ), - 0, - ) + t, - 0, - ), - ), - ); - } - splitBill(inventories: string[], table: Table): Observable { return this.ser.splitBill(this.bill.id as string, inventories, table, this.updateTable); } @@ -369,43 +378,49 @@ export class BillService { } private happyHourItemsBalanced(): boolean { - for (const kot of this.bill.kots) { + return this.bill.kots.every((kot) => { const happyHourItems = kot.inventories .filter((x) => x.isHappyHour) - .map((x) => ({ id: x.sku.id as string, quantity: x.quantity })); - for (const item of happyHourItems) { - const q = kot.inventories.find((x) => !x.isHappyHour && x.sku.id === item.id && x.quantity === item.quantity); - if (q === undefined) { - return false; - } - } - } - return true; - } - - private happyHourItemsMoreThanRegular(): boolean { - // This is for the whole bill. eg. Kot 1 => Reg 2 + HH 2; Kot 2 => Reg 4; Kot 3 => Reg - 4 - // This is pass okay in happy hours items balanced, but overall this is wrong. Hence this check - const invs: Record = {}; - for (const kot of this.bill.kots) { - for (const inventory of kot.inventories) { - const pid = inventory.sku.id as string; - if (invs[pid] === undefined) { - invs[pid] = { normal: 0, happy: 0 }; - } - if (inventory.isHappyHour) { - invs[pid].happy += inventory.quantity; - } else { - invs[pid].normal += inventory.quantity; - } - } - } - for (const [, value] of Object.entries(invs)) { - if (value.happy > value.normal) { + .map((x) => ({ id: x.sku.id as string, quantity: x.quantity })) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + const happyHourIds = happyHourItems.map((x) => x.id); + const rest = kot.inventories + .filter((x) => !x.isHappyHour && happyHourIds.indexOf(x.sku.id as string) !== -1) + .map((x) => ({ id: x.sku.id as string, quantity: x.quantity })) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + if (happyHourIds.length === 0) { return true; } - } - return false; + return ( + happyHourItems.length === rest.length && + happyHourItems.every((v, i) => v.id === rest[i].id && v.quantity === rest[i].quantity) + ); + }); + } + + private regularItemsMoreThanHappyHour(): boolean { + // This is for the whole bill. eg. Kot 1 => Reg 2 + HH 2; Kot 2 => Reg 4; Kot 3 => Reg - 4 + // This is pass okay in happy hours items balanced, but overall this is wrong. Hence this check + + const inventories = this.bill.kots + .flatMap((kot) => kot.inventories) + .reduce((acc: { id: string; quantity: number; isHappyHour: boolean }[], curr) => { + const existing = acc.find((x) => x.id === curr.sku.id && x.isHappyHour === curr.isHappyHour); + if (existing) { + existing.quantity += curr.quantity; + } else { + acc.push({ id: curr.sku.id as string, quantity: curr.quantity, isHappyHour: curr.isHappyHour }); + } + return acc; + }, []) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + const happyHourItems = inventories.filter((x) => x.isHappyHour); + const happyHourIds = happyHourItems.map((x) => x.id); + const rest = inventories.filter((x) => !x.isHappyHour && happyHourIds.indexOf(x.id as string) !== -1); + return ( + happyHourItems.length === rest.length && + happyHourItems.every((hh, index) => hh.id === rest[index].id && hh.quantity <= rest[index].quantity) + ); } public canDeactivate(): boolean { diff --git a/bookie/src/app/sales/bills/bill-row.ts b/bookie/src/app/sales/bills/bill-row.ts new file mode 100644 index 00000000..3bbcfea6 --- /dev/null +++ b/bookie/src/app/sales/bills/bill-row.ts @@ -0,0 +1,18 @@ +import { Inventory } from './inventory'; +import { Kot } from './kot'; + +export class BillRow { + id: string; + kind: 'kot' | 'inv' | 'child'; + kot: Kot; + inv: Inventory | undefined; + parent: Inventory | undefined; + public constructor(init?: Partial) { + this.id = ''; + this.kind = 'kot'; + this.kot = new Kot(); + this.inv = undefined; + this.parent = undefined; + Object.assign(this, init); + } +} diff --git a/bookie/src/app/sales/bills/bill-selection-item.ts b/bookie/src/app/sales/bills/bill-selection-item.ts deleted file mode 100644 index 1ab01d3d..00000000 --- a/bookie/src/app/sales/bills/bill-selection-item.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class BillSelectionItem { - kotId?: string; - inventoryId?: string; - skuId: string; - isHappyHour: boolean; - - public constructor(init?: Partial) { - this.kotId = undefined; - this.inventoryId = undefined; - this.skuId = ''; - this.isHappyHour = false; - Object.assign(this, init); - } -} diff --git a/bookie/src/app/sales/bills/bills-datasource.ts b/bookie/src/app/sales/bills/bills-datasource.ts index 9119d3b3..dd418a25 100644 --- a/bookie/src/app/sales/bills/bills-datasource.ts +++ b/bookie/src/app/sales/bills/bills-datasource.ts @@ -1,15 +1,51 @@ import { DataSource } from '@angular/cdk/collections'; -import { Observable } from 'rxjs'; +import { map, Observable, tap } from 'rxjs'; -import { BillViewItem } from '../../core/bill-view-item'; +import { BillRow } from './bill-row'; +import { Kot } from './kot'; -export class BillsDataSource extends DataSource { - constructor(private data: Observable) { +export class BillsDataSource extends DataSource { + private data: Kot[] = []; + constructor(private readonly dataObs: Observable) { super(); } - connect(): Observable { - return this.data; + connect(): Observable { + return this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + map((d) => + d.flatMap((k) => { + const kotRow: BillRow = new BillRow({ kind: 'kot', kot: k, id: k.id! }); + const rows: BillRow[] = []; + for (const inv of k.inventories) { + if (!inv.id) { + inv.clientId = inv.clientId || crypto.randomUUID(); + } + rows.push(new BillRow({ kind: 'inv', kot: k, inv, id: `${k.id}-${inv.id || inv.clientId}` })); + if (inv.type === 'bundle') { + for (const child of inv.children) { + if (!child.id) { + child.clientId = child.clientId || crypto.randomUUID(); + } + child.parentId = inv.id || inv.clientId; + rows.push( + new BillRow({ + kind: 'child', + kot: k, + parent: inv, + inv: child, + id: `${k.id}-${child.id || child.clientId}`, + }), + ); + } + } + } + return [kotRow, ...rows]; + }), + ), + ); } disconnect() {} diff --git a/bookie/src/app/sales/bills/bills.component.html b/bookie/src/app/sales/bills/bills.component.html index 16982dfc..0b01f56e 100644 --- a/bookie/src/app/sales/bills/bills.component.html +++ b/bookie/src/app/sales/bills/bills.component.html @@ -1,6 +1,5 @@

Bill

- - +
Bill / KOT number @@ -35,9 +34,9 @@ - + - @if (row.isOldKot) { + @if (row.id) { } - @if (!row.isKot) { - - - } - - + - - {{ row.info }} - + + + + + + + + + + + {{ row.id ? `Kot: ${row.kot.code} / ${row.kot.date} (${row.kot.user.name})` : '== New Kot ==' }} + + + + + {{ `${row.inv.sku.name} @ ${row.inv.price} - ${discountPct(row.inv)}%` }}
    - @for (m of row.modifiers; track m.id) { + @for (m of row.inv.modifiers; track m.id) { +
  • {{ m.name }}
  • + } +
+
+
+ + + {{ `${row.inv.sku.name} @ ${row.inv.price} - ${discountPct(row.inv)}%` }} +
    + @for (m of row.inv.modifiers; track m.id) {
  • {{ m.name }}
  • }
+ + Quantity + + + + Quantity - @if (!row.isKot) { - - } - @if (!row.isKot) { - - } - @if (!row.isKot) { - - } - @if (!row.isKot) { - - } - @if (row.isKot) { - - } + + + + + + Quantity + + + + + + + + + + diff --git a/bookie/src/app/sales/bills/bills.component.sass b/bookie/src/app/sales/bills/bills.component.sass index 1cf8c3fe..9f9ee187 100644 --- a/bookie/src/app/sales/bills/bills.component.sass +++ b/bookie/src/app/sales/bills/bills.component.sass @@ -1,88 +1,62 @@ -@use '@angular/material' as mat - -$my-grey: mat.m2-define-palette(mat.$m2-grey-palette) -$my-green: mat.m2-define-palette(mat.$m2-green-palette) - -.right-align - display: flex - justify-content: flex-end - table width: 100% -.mat-column-select - flex: 0 0 60px +.mat-column-selectKot, .mat-column-selectInv, .mat-column-selectChild + flex: 0 0 48px + width: 48px + max-width: 48px + padding-left: 4px + padding-right: 4px -.grey900, .mat-column-amount-title, .mat-column-amount-amount +.mat-column-kotActions, .mat-column-quantity, .mat-column-childQuantity + flex: 0 0 min-content + +.mat-column-amount-title, .mat-column-amount-amount background-color: #1b5e20 color: #ffffff font-size: 1.2em - // color: mat.m2-get-color-from-palette($my-grey, '900-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 900) -.grey700, .mat-column-tax-title, .mat-column-tax-amount +.mat-column-tax-title, .mat-column-tax-amount background-color: #388e3c color: #ffffff - // color: mat.m2-get-color-from-palette($my-grey, '900-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 900) -.grey500, .mat-column-discount-title, .mat-column-discount-amount +.mat-column-discount-title, .mat-column-discount-amount, .mat-column-hh-title, .mat-column-hh-amount background-color: #4caf50 color: #000000 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) -.grey300, .mat-column-hh-title, .mat-column-hh-amount, .mat-column-gross-title, .mat-column-gross-amount +.mat-column-gross-title, .mat-column-gross-amount background-color: #81c784 color: #000000 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) -.blue400, .old-kot +.old-kot background-color: #42a5f5 color: #ffffff - // color: mat.m2-get-color-from-palette($my-green, 700) - // background-color: mat.m2-get-color-from-palette($my-green, 500) -.blue800, .new-kot +.new-kot background-color: #1565c0 color: #ffffff - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) -.red100, .is-printed - // background-color: #ffcdd2 - // color: #000000 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) +.is-printed + background-color: #ffcdd2 + color: #000000 .deep-purple-50 background-color: #ede7f6 color: #000000 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) .deep-purple-100 background-color: #d1c4e9 color: #000000 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) .deep-purple-200 background-color: #b39ddb color: #000000 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) .yellow300, .hh-new background-color: #fff176 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) .hh-printed background-color: #f7ca18 - // color: mat.m2-get-color-from-palette($my-grey, '300-contrast') - // background: mat.m2-get-color-from-palette($my-grey, 300) // https://github.com/btxtiger/mat-icon-button-sizes $button-size: 32px @@ -109,3 +83,8 @@ $icon-size: 19px .mat-mdc-button-touch-target width: $button-size !important height: $button-size !important + +.child-row + font: var(--mat-sys-label-small) + letter-spacing: var(--mat-sys-label-small-tracking) + color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent) diff --git a/bookie/src/app/sales/bills/bills.component.ts b/bookie/src/app/sales/bills/bills.component.ts index d57f736a..686322d9 100644 --- a/bookie/src/app/sales/bills/bills.component.ts +++ b/bookie/src/app/sales/bills/bills.component.ts @@ -1,4 +1,4 @@ -import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { CommonModule, CurrencyPipe } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -12,10 +12,10 @@ import { Observable } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { AuthService } from '../../auth/auth.service'; -import { BillViewItem } from '../../core/bill-view-item'; import { Customer } from '../../core/customer'; import { Table } from '../../core/table'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; +import { MathService } from '../../shared/math.service'; import { TableService } from '../../tables/table.service'; import { BillService } from '../bill.service'; import { ChooseCustomerComponent } from '../choose-customer/choose-customer.component'; @@ -23,7 +23,7 @@ import { PaxComponent } from '../pax/pax.component'; import { QuantityComponent } from '../quantity/quantity.component'; import { TablesDialogComponent } from '../tables-dialog/tables-dialog.component'; import { Bill } from './bill'; -import { BillSelectionItem } from './bill-selection-item'; +import { BillRow } from './bill-row'; import { BillsDataSource } from './bills-datasource'; import { Inventory } from './inventory'; import { Kot } from './kot'; @@ -34,7 +34,7 @@ import { VoucherType } from './voucher-type'; templateUrl: './bills.component.html', styleUrls: ['./bills.component.sass'], imports: [ - AsyncPipe, + CommonModule, CurrencyPipe, MatButtonModule, MatCheckboxModule, @@ -49,13 +49,16 @@ export class BillsComponent implements OnInit { private router = inject(Router); private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); + private math = inject(MathService); private auth = inject(AuthService); bs = inject(BillService); private tSer = inject(TableService); dataSource: BillsDataSource = new BillsDataSource(this.bs.dataObs); /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ - displayedColumns: string[] = ['select', 'info', 'quantity']; + kotColumns: string[] = ['selectKot', 'infoKot', 'kotActions']; + displayedColumns: string[] = ['selectInv', 'infoInv', 'quantity']; + childColumns: string[] = ['selectChild', 'infoChild', 'childQuantity']; ngOnInit() { this.route.data.subscribe((value) => { @@ -102,62 +105,28 @@ export class BillsComponent implements OnInit { }); } - isAllSelected(kotView: BillViewItem): boolean { - const kot = this.bs.bill.kots.find((k) => k.id === kotView.kotId) as Kot; + isAllSelected(row: BillRow): boolean { + const kot = row.kot as Kot; return kot.inventories.reduce( - (p: boolean, c: Inventory) => - p && - this.bs.selection.isSelected( - JSON.stringify( - new BillSelectionItem({ - kotId: kot.id, - inventoryId: c.id, - skuId: c.sku.id, - isHappyHour: c.isHappyHour, - }), - ), - ), + (p: boolean, c: Inventory) => p && this.bs.selection.isSelected(`${kot.id}-${c.id || c.clientId}`), true, ); } - toggle(invView: BillViewItem) { - const key = JSON.stringify( - new BillSelectionItem({ - kotId: invView.kotId, - inventoryId: invView.id, - skuId: invView.skuId, - isHappyHour: invView.isHappyHour, - }), - ); - this.bs.selection.toggle(key); + toggle(row: BillRow) { + this.bs.selection.toggle(row.id); } - isSelected(invView: BillViewItem): boolean { - const key = JSON.stringify( - new BillSelectionItem({ - kotId: invView.kotId, - inventoryId: invView.id, - skuId: invView.skuId, - isHappyHour: invView.isHappyHour, - }), - ); - return this.bs.selection.isSelected(key); + isSelected(row: BillRow): boolean { + return this.bs.selection.isSelected(row.id); } - isAnySelected(kotView: BillViewItem) { - const kot = this.bs.bill.kots.find((k) => k.id === kotView.kotId) as Kot; + isAnySelected(row: BillRow) { + const kot = row.kot as Kot; let total = 0; let found = 0; for (const item of kot.inventories) { - const key = JSON.stringify( - new BillSelectionItem({ - kotId: kot.id, - inventoryId: item.id, - skuId: item.sku.id, - isHappyHour: item.isHappyHour, - }), - ); + const key = `${kot.id}-${item.id || item.clientId}`; total += 1; if (this.bs.selection.isSelected(key)) { found += 1; @@ -166,18 +135,11 @@ export class BillsComponent implements OnInit { return found > 0 && found < total; } - masterToggle(kotView: BillViewItem) { - const isAllSelected = this.isAllSelected(kotView); - const kot = this.bs.bill.kots.find((k) => k.id === kotView.kotId) as Kot; + masterToggle(row: BillRow) { + const isAllSelected = this.isAllSelected(row); + const kot = row.kot as Kot; for (const item of kot.inventories) { - const key = JSON.stringify( - new BillSelectionItem({ - kotId: kot.id, - inventoryId: item.id, - skuId: item.sku.id, - isHappyHour: item.isHappyHour, - }), - ); + const key = `${kot.id}-${item.id || item.clientId}`; if (isAllSelected) { this.bs.selection.deselect(key); } else { @@ -186,21 +148,26 @@ export class BillsComponent implements OnInit { } } - trackByKotskuId(index: number, row: BillViewItem): string { - // Fallbacks in case fields are undefined/null - const kotId = row.kotId ?? ''; - const skuId = row.skuId ?? ''; - return `${kotId}_${skuId}`; + trackByRow = (_: number, row: BillRow) => { + return row.id; + }; + + isChild = (_: number, row: BillRow) => row.kind === 'child'; + + isKot = (_: number, row: BillRow) => row.kind === 'kot'; + + discountPct(row: Inventory): number { + return this.math.halfRoundEven(row.discount * 100, 2); } - addOne(item: BillViewItem): void { + addOne(item: BillRow): void { this.bs.addOne(item); } - quantity(item: BillViewItem): void { + quantity(item: BillRow): void { const dialogRef = this.dialog.open(QuantityComponent, { // width: '750px', - data: item.quantity, + data: item.inv?.quantity, }); dialogRef.afterClosed().subscribe((result: boolean | number) => { @@ -211,12 +178,12 @@ export class BillsComponent implements OnInit { }); } - subtractOne(item: BillViewItem): void { + subtractOne(item: BillRow): void { const canEdit = this.auth.allowed('edit-printed-product'); this.bs.subtractOne(item, canEdit); } - modifier(item: BillViewItem): void { + modifier(item: BillRow): void { this.bs.modifier(item); } @@ -257,7 +224,7 @@ export class BillsComponent implements OnInit { ); } - moveKot(kot: BillViewItem) { + moveKot(kot: BillRow): void { const canMergeTables = this.auth.allowed('merge-tables'); this.chooseTable(canMergeTables) .pipe( @@ -270,9 +237,9 @@ export class BillsComponent implements OnInit { return this.bs.moveTable(table); } if (table.status) { - return this.bs.mergeKot(kot.kotId as string, table); + return this.bs.mergeKot(kot.kot.id as string, table); } - return this.bs.moveKot(kot.kotId as string, table); + return this.bs.moveKot(kot.kot.id as string, table); }), ) .subscribe({ @@ -286,8 +253,9 @@ export class BillsComponent implements OnInit { }); } - rowQuantityDisabled(row: BillViewItem) { - if (!row.isPrinted) { + rowQuantityDisabled(row: BillRow): boolean { + const isPrinted = !!(row.inv as Inventory).id; + if (!isPrinted) { return false; } if (this.bs.bill.voucherType === VoucherType.Void) { diff --git a/bookie/src/app/sales/bills/inventory.ts b/bookie/src/app/sales/bills/inventory.ts index e5d9cc23..26d3b007 100644 --- a/bookie/src/app/sales/bills/inventory.ts +++ b/bookie/src/app/sales/bills/inventory.ts @@ -1,30 +1,43 @@ import { Modifier } from '../../core/modifier'; +import { ProductQuery } from '../../core/product-query'; import { Tax } from '../../core/tax'; -import { ProductLink } from './product-link'; export class Inventory { id: string | undefined; - sku: ProductLink; + clientId: string | undefined; + sku: ProductQuery; quantity: number; price: number; isHappyHour: boolean; - taxRate: number; + type: 'regular' | 'bundle' | 'bundle_item'; + parentId?: string; tax: Tax; + taxRate: number; discount: number; modifiers: Modifier[]; sortOrder: number; + children: Inventory[]; + + public get stableId(): string | undefined { + return this.id ?? this.clientId; + } public constructor(init?: Partial) { this.id = undefined; - this.sku = new ProductLink(); + if (!this.id) { + this.clientId = crypto.randomUUID(); + } + this.sku = new ProductQuery(); this.quantity = 0; this.price = 0; this.isHappyHour = false; + this.type = 'regular'; this.taxRate = 0; this.tax = new Tax(); this.discount = 0; this.modifiers = []; this.sortOrder = 0; + this.children = []; Object.assign(this, init); } } diff --git a/bookie/src/app/sales/home/sales-home.component.ts b/bookie/src/app/sales/home/sales-home.component.ts index 0e046ba2..2b149fbb 100644 --- a/bookie/src/app/sales/home/sales-home.component.ts +++ b/bookie/src/app/sales/home/sales-home.component.ts @@ -15,7 +15,6 @@ import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dial import { TableService } from '../../tables/table.service'; import { BillTypeComponent } from '../bill-type/bill-type.component'; import { BillService } from '../bill.service'; -import { BillSelectionItem } from '../bills/bill-selection-item'; import { VoucherType } from '../bills/voucher-type'; import { CustomerDiscountsService } from '../discount/customer-discounts.service'; import { DiscountComponent } from '../discount/discount.component'; @@ -412,9 +411,8 @@ export class SalesHomeComponent { } splitBillWithSelection() { - const inventories: string[] = this.bs.selection.selected.map( - (x: string) => (JSON.parse(x) as BillSelectionItem).inventoryId as string, - ); + const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const inventories: string[] = this.bs.selection.selected.map((v) => v.match(uuidRegex)?.[0] as string); return observableOf(inventories); } } diff --git a/bookie/src/app/sales/products/products.component.ts b/bookie/src/app/sales/products/products.component.ts index 4b9838a3..4ed0a8c9 100644 --- a/bookie/src/app/sales/products/products.component.ts +++ b/bookie/src/app/sales/products/products.component.ts @@ -6,7 +6,6 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { ProductQuery } from '../../core/product-query'; import { BillService } from '../bill.service'; -import { ProductLink } from '../bills/product-link'; @Component({ selector: 'app-products', @@ -31,6 +30,6 @@ export class ProductsComponent implements OnInit { if (product.isNotAvailable) { return; } - this.bs.addSku(new ProductLink(product), 1, 0); + this.bs.addOneSku(product); } } diff --git a/bookie/src/app/sales/tables-dialog/tables-dialog.component.html b/bookie/src/app/sales/tables-dialog/tables-dialog.component.html index 9741c6e3..fc33df00 100644 --- a/bookie/src/app/sales/tables-dialog/tables-dialog.component.html +++ b/bookie/src/app/sales/tables-dialog/tables-dialog.component.html @@ -1,7 +1,7 @@

Tables

- @for (table of list; track table) { + @for (table of list | async; track table) { >(MatDialogRef); @@ -22,16 +30,13 @@ export class TablesDialogComponent { canChooseRunning: boolean; }>(MAT_DIALOG_DATA); - list: Table[] = []; + list: Observable; canChooseRunning: boolean; selected: Table | null; constructor() { const data = this.data; - - this.data.list.subscribe((list: Table[]) => { - this.list = list; - }); + this.list = data.list; this.canChooseRunning = data.canChooseRunning; this.selected = null; } diff --git a/bookie/src/layout.sass b/bookie/src/layout.sass index ed4ceed2..25bba9fc 100644 --- a/bookie/src/layout.sass +++ b/bookie/src/layout.sass @@ -46,5 +46,9 @@ .center text-align: center +.right-align + display: flex + justify-content: flex-end + .warn background-color: red diff --git a/bookie/src/square-buttons.sass b/bookie/src/square-buttons.sass index 623972dc..628bb9d5 100644 --- a/bookie/src/square-buttons.sass +++ b/bookie/src/square-buttons.sass @@ -38,12 +38,12 @@ color: get-on-color($sbsp) !important background: $sbsp !important -.accent +.square-button.accent $a: map.get(mat.$orange-palette, 70) color: get-on-color($a) !important background: $a !important -.strong-accent +.square-button.strong-accent $sa: map.get(mat.$orange-palette, 20) color: get-on-color($sa) !important background: $sa !important diff --git a/bookie/src/styles.sass b/bookie/src/styles.sass index 34f19461..1d70917f 100644 --- a/bookie/src/styles.sass +++ b/bookie/src/styles.sass @@ -4,16 +4,11 @@ @use 'square-buttons' @use 'layout' -// @tailwind base -// @tailwind components -// @tailwind utilities - - @include mat.core() -html +:root // color-scheme: light dark @include mat.theme(( color: ( @@ -23,9 +18,9 @@ html ), typography: ( plain-family: Montserrat, - brand-family: Montserrat + brand-family: Montserrat, ), - density: 0 + density: 0, )) font-family: "Helvetica Neue", Montserrat, sans-serif @@ -37,16 +32,15 @@ a color: rgb(0, 0, 238) text-decoration: underline -.center - text-align: center -.warn - background-color: red - -button.mat-primary +.mat-mdc-button.mat-primary, +.mat-mdc-raised-button.mat-primary, +.mat-mdc-unelevated-button.mat-primary background: var(--mat-sys-primary) !important color: var(--mat-sys-on-primary) !important -button.mat-warn +.mat-mdc-button.mat-warn, +.mat-mdc-raised-button.mat-warn, +.mat-mdc-unelevated-button.mat-warn background: var(--mat-sys-error) !important color: var(--mat-sys-on-error) !important