diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/Dockerfile b/Dockerfile index 1c2af45..027f148 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,17 +25,9 @@ RUN \ else echo "Lockfile not found." && exit 1; \ fi -FROM python:3.13 AS runner +FROM python:3.14 AS runner LABEL maintainer="Amritanshu " -RUN apt-get update && \ - apt-get install -y locales && \ - sed --in-place --expression='s/# en_IN UTF-8/en_IN UTF-8/' /etc/locale.gen && \ - dpkg-reconfigure --frontend=noninteractive locales - -ENV LANG=en_IN -ENV LC_ALL=en_IN - # Install Poetry RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python - && \ cd /usr/local/bin && \ diff --git a/README.md b/README.md index a87f3a2..7d891a5 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-d ## 2. Install Python 3.14 using pyenv ```zsh -pyenv install 3.14.0 +pyenv install 3.14 ``` Set the local Python version for this project: ```zsh -pyenv local 3.14.0 +pyenv local 3.14 ``` Verify: @@ -79,7 +79,16 @@ export PATH="$HOME/.local/bin:$PATH" Enable tab completion for Zsh ```zsh -poetry completions zsh > ~/.zfunc/_poetry +mkdir $ZSH_CUSTOM/plugins/poetry +poetry completions zsh > $ZSH_CUSTOM/plugins/poetry/_poetry +``` + +You must then add poetry to your plugins array in ~/.zshrc: +```zsh +plugins( + poetry + ... + ) ``` Verify: diff --git a/barker/alembic/versions/32c508eed4df_skus.py b/barker/alembic/versions/32c508eed4df_skus.py index d00ea76..1c5c7ba 100644 --- a/barker/alembic/versions/32c508eed4df_skus.py +++ b/barker/alembic/versions/32c508eed4df_skus.py @@ -75,7 +75,7 @@ def upgrade(): ["sku_id"], ["stock_keeping_units.id"], name=op.f("fk_sku_versions_sku_id_stock_keeping_units") ), sa.PrimaryKeyConstraint("id", name=op.f("pk_sku_versions")), - sa.CheckConstraint("length(trim(units)) >= 1", name="ck_sku_versions_units_not_blank"), + sa.CheckConstraint("length(trim(units)) >= 1", name="units_not_blank"), ) # --------------------------------------------------------------------- @@ -223,7 +223,7 @@ def upgrade(): op.alter_column("product_versions", "units", new_column_name="fraction_units") op.drop_constraint(op.f("fk_products_menu_category_id_menu_categories"), "product_versions", type_="foreignkey") op.create_check_constraint( - "ck_product_versions_name_not_blank", + "name_not_blank", "product_versions", "length(trim(name)) >= 1", ) diff --git a/barker/alembic/versions/367ecf7b898f_bundles.py b/barker/alembic/versions/367ecf7b898f_bundles.py new file mode 100644 index 0000000..affe8fa --- /dev/null +++ b/barker/alembic/versions/367ecf7b898f_bundles.py @@ -0,0 +1,147 @@ +"""bundles + +Revision ID: 367ecf7b898f +Revises: 1e0daf6bc1ae +Create Date: 2026-01-28 16:11:21.874409 + +""" + +import sqlalchemy as sa + +from sqlalchemy.dialects import postgresql + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "367ecf7b898f" +down_revision = "1e0daf6bc1ae" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "bundle_items", + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("bundle_id", sa.Uuid(), nullable=False), + sa.Column("item_id", sa.Uuid(), nullable=False), + sa.Column("quantity", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("sale_price", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("print_in_bill", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_till", sa.Date(), nullable=True), + postgresql.ExcludeConstraint( + (sa.column("bundle_id"), "="), + (sa.column("item_id"), "="), + (sa.text("daterange(valid_from, valid_till, '[]')"), "&&"), + using="gist", + name=op.f("uq_bundle_items_bundle_id_item_id"), + ), + sa.ForeignKeyConstraint( + ["bundle_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_bundle_id_stock_keeping_units") + ), + sa.ForeignKeyConstraint( + ["item_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_item_id_stock_keeping_units") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_bundle_items")), + ) + + inventory_type = sa.Enum("regular", "bundle", "bundle_item", name="inventorytype") + inventory_type.create(op.get_bind()) + op.add_column("inventories", sa.Column("type", inventory_type, server_default=sa.text("'regular'"), nullable=False)) + op.add_column("inventories", sa.Column("parent_id", sa.Uuid(), nullable=True)) + op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique") + op.create_unique_constraint( + op.f("uq_inventories_kot_id"), + "inventories", + ["kot_id", "sku_id", "is_happy_hour", "type", "parent_id"], + postgresql_nulls_not_distinct=True, + ) + op.create_foreign_key( + op.f("fk_inventories_parent_id_inventories"), "inventories", "inventories", ["parent_id"], ["id"] + ) + op.add_column( + "stock_keeping_units", sa.Column("is_bundle", sa.Boolean(), server_default=sa.text("false"), nullable=False) + ) + mc = sa.table( + "menu_categories", + sa.column("id", sa.Uuid()), + sa.column("name", sa.Unicode(length=255)), + sa.column("is_active", sa.Boolean()), + sa.column("is_fixture", sa.Boolean()), + sa.column("sort_order", sa.Integer()), + ) + + op.execute( + mc.insert().values( + id="6752ed9d-6f1a-4941-940a-17759dcc6720", + name="Bundles", + is_active=True, + is_fixture=True, + sort_order=0, + ) + ) + + customers = sa.table( + "customers", + sa.column("id", sa.Uuid), + sa.column("name", sa.Text), + sa.column("phone", sa.Text), + ) + + guest_book = sa.table( + "guest_book", + sa.column("customer_id", sa.Uuid), + ) + + voucher = sa.table( + "vouchers", + sa.column("customer_id", sa.Uuid), + ) + + bad_customer_ids = sa.select(customers.c.id).where( + sa.func.length(sa.func.trim(customers.c.phone)) == 0, + ) + + op.execute(guest_book.delete().where(guest_book.c.customer_id.in_(bad_customer_ids))) + + op.execute(voucher.update().where(voucher.c.customer_id.in_(bad_customer_ids)).values(customer_id=None)) + + op.execute(customers.delete().where(customers.c.id.in_(bad_customer_ids))) + + op.execute( + customers.update() + .where( + sa.func.length(sa.func.trim(customers.c.name)) == 0, + ) + .values(name=customers.c.phone) + ) + + with op.batch_alter_table("customers") as batch_op: + batch_op.create_check_constraint("name_not_blank", sa.text("length(trim(both from name)) >= 1")) + batch_op.create_check_constraint("phone_not_blank", sa.text("length(trim(both from phone)) >= 1")) + + +def downgrade(): + op.drop_table("bundle_items") + op.drop_column("stock_keeping_units", "is_bundle") + op.drop_constraint(op.f("fk_inventories_parent_id_inventories"), "inventories", type_="foreignkey") + op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique") + op.create_unique_constraint( + op.f("uq_inventories_kot_id"), + "inventories", + ["kot_id", "sku_id", "is_happy_hour", "price"], + postgresql_nulls_not_distinct=False, + ) + op.drop_column("inventories", "parent_id") + op.drop_column("inventories", "type") + mc = sa.table( + "menu_categories", + sa.column("id", sa.Uuid()), + ) + op.execute(mc.delete().where(mc.c.id == "6752ed9d-6f1a-4941-940a-17759dcc6720")) + + with op.batch_alter_table("customers") as batch_op: + batch_op.drop_constraint("name_not_blank", type_="check") + batch_op.drop_constraint("phone_not_blank", type_="check") diff --git a/barker/alembic/versions/7e9944b430d6_booking.py b/barker/alembic/versions/7e9944b430d6_booking.py index edd9a09..ea15aae 100644 --- a/barker/alembic/versions/7e9944b430d6_booking.py +++ b/barker/alembic/versions/7e9944b430d6_booking.py @@ -106,7 +106,7 @@ def upgrade(): op.execute(gbt.update().values(type="walk_in")) with op.batch_alter_table("guest_book") as batch_op: batch_op.alter_column("type", existing_type=guestbooktype, nullable=False) - batch_op.create_check_constraint("ck_guest_book_nulls", sa.text("num_nonnulls(booking_date, arrival_date) > 0")) + batch_op.create_check_constraint("nulls", sa.text("num_nonnulls(booking_date, arrival_date) > 0")) # ### end Alembic commands ### diff --git a/barker/barker/db/base.py b/barker/barker/db/base.py index 2316f3e..381d413 100644 --- a/barker/barker/db/base.py +++ b/barker/barker/db/base.py @@ -1,6 +1,7 @@ # Import all the models, so that Base has them before being # imported by Alembic from ..models.bill import Bill +from ..models.bundle_item import BundleItem from ..models.customer import Customer from ..models.customer_discount import CustomerDiscount from ..models.db_setting import DbSetting @@ -9,6 +10,7 @@ from ..models.food_table import FoodTable from ..models.guest_book import GuestBook from ..models.inventory import Inventory from ..models.inventory_modifier import InventoryModifier +from ..models.inventory_type import InventoryType from ..models.kot import Kot from ..models.login_history import LoginHistory from ..models.menu_category import MenuCategory @@ -43,6 +45,7 @@ from .base_class import reg __all__ = [ "Bill", + "BundleItem", "Customer", "CustomerDiscount", "DbSetting", @@ -51,6 +54,7 @@ __all__ = [ "GuestBook", "Inventory", "InventoryModifier", + "InventoryType", "Kot", "LoginHistory", "MenuCategory", diff --git a/barker/barker/main.py b/barker/barker/main.py index fe63574..68e38e1 100644 --- a/barker/barker/main.py +++ b/barker/barker/main.py @@ -8,6 +8,7 @@ from starlette.middleware.sessions import SessionMiddleware from .core.config import settings from .db.base import reg # noqa: F401 from .routers import ( + bundle, customer, customer_discount, db_settings, @@ -72,6 +73,7 @@ app.include_router(printer.router, prefix="/api/printers", tags=["printers"]) app.include_router(menu_category.router, prefix="/api/menu-categories", tags=["products"]) app.include_router(product.router, prefix="/api/products", tags=["products"]) +app.include_router(bundle.router, prefix="/api/bundles", tags=["products"]) app.include_router(temporal_product.router, prefix="/api/temporal-products", tags=["products"]) app.include_router(device.router, prefix="/api/devices", tags=["devices"]) app.include_router(sale_category.router, prefix="/api/sale-categories", tags=["products"]) diff --git a/barker/barker/models/bundle_item.py b/barker/barker/models/bundle_item.py new file mode 100644 index 0000000..a2911bc --- /dev/null +++ b/barker/barker/models/bundle_item.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import uuid + +from datetime import date +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Uuid, func, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .stock_keeping_unit import StockKeepingUnit + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class BundleItem: + __tablename__ = "bundle_items" + + id: Mapped[uuid.UUID] = mapped_column( + Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") + ) + bundle_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + item_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + quantity: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + sale_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + print_in_bill: Mapped[bool] = mapped_column(Boolean, server_default=text("true"), nullable=False) + + valid_from: Mapped[date | None] = mapped_column(Date(), nullable=True) + valid_till: Mapped[date | None] = mapped_column(Date(), nullable=True) + + bundle: Mapped[StockKeepingUnit] = relationship( + "StockKeepingUnit", back_populates="bundle_items", foreign_keys=[bundle_id] + ) + item: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", foreign_keys=[item_id]) + + __table_args__ = ( + postgresql.ExcludeConstraint( + (bundle_id, "="), + (item_id, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), + ) + + def __init__( + self, + quantity: Decimal, + sale_price: Decimal, + print_in_bill: bool, + bundle_id: uuid.UUID, + item_id: uuid.UUID, + bundle: StockKeepingUnit | None = None, + item: StockKeepingUnit | None = None, + valid_from: date | None = None, + valid_till: date | None = None, + id_: uuid.UUID | None = None, + ): + self.quantity = quantity + self.sale_price = sale_price + self.print_in_bill = print_in_bill + self.valid_from = valid_from + self.valid_till = valid_till + if bundle_id is not None: + self.bundle_id = bundle_id + if item_id is not None: + self.item_id = item_id + if bundle is not None: + self.bundle = bundle + if item is not None: + self.item = item + if id_ is not None: + self.id = id_ diff --git a/barker/barker/models/customer.py b/barker/barker/models/customer.py index e17d9a6..c0c9781 100644 --- a/barker/barker/models/customer.py +++ b/barker/barker/models/customer.py @@ -4,7 +4,7 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import Boolean, Text, Uuid, text +from sqlalchemy import Boolean, CheckConstraint, Text, Uuid, text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import expression @@ -18,7 +18,16 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class Customer: __tablename__ = "customers" - + __table_args__ = ( + CheckConstraint( + "length(trim(both from name)) >= 1", + name="name_not_blank", + ), + CheckConstraint( + "length(trim(both from phone)) >= 1", + name="phone_not_blank", + ), + ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) name: Mapped[str] = mapped_column(Text, nullable=False) phone: Mapped[str] = mapped_column(Text, nullable=False, unique=True) diff --git a/barker/barker/models/guest_book.py b/barker/barker/models/guest_book.py index 154c0f4..ba29832 100644 --- a/barker/barker/models/guest_book.py +++ b/barker/barker/models/guest_book.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class GuestBook: __tablename__ = "guest_book" - __table_args__ = (CheckConstraint("num_nonnulls(booking_date, arrival_date) > 0", name="ck_guest_book_nulls"),) + __table_args__ = (CheckConstraint("num_nonnulls(booking_date, arrival_date) > 0", name="nulls"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) customer_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("customers.id"), nullable=False) diff --git a/barker/barker/models/inventory.py b/barker/barker/models/inventory.py index d2dea1a..bdd74b5 100644 --- a/barker/barker/models/inventory.py +++ b/barker/barker/models/inventory.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from sqlalchemy import ( Boolean, ColumnElement, + Enum, ForeignKey, Integer, Numeric, @@ -22,6 +23,7 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db.base_class import reg +from .inventory_type import InventoryType from .tax import Tax @@ -34,7 +36,9 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class Inventory: __tablename__ = "inventories" - __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price"),) + __table_args__ = ( + UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "type", "parent_id", postgresql_nulls_not_distinct=True), + ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) kot_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("kots.id"), nullable=False, index=True) @@ -46,28 +50,41 @@ class Inventory: tax_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("taxes.id"), nullable=False) discount: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False) + type_: Mapped[InventoryType] = mapped_column( + "type", Enum(InventoryType), server_default=text("regular"), nullable=False + ) + parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True) kot: Mapped[Kot] = relationship(back_populates="inventories") tax: Mapped[Tax] = relationship(back_populates="inventories") sku: Mapped[StockKeepingUnit] = relationship(back_populates="inventories") + parent: Mapped[Inventory | None] = relationship("Inventory", remote_side="Inventory.id", back_populates="children") + children: Mapped[list[Inventory]] = relationship("Inventory", back_populates="parent") modifiers: Mapped[list[InventoryModifier]] = relationship(back_populates="inventory") 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 @@ -78,6 +95,11 @@ class Inventory: self.tax_id = tax_id 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/inventory_type.py b/barker/barker/models/inventory_type.py new file mode 100644 index 0000000..a458ee2 --- /dev/null +++ b/barker/barker/models/inventory_type.py @@ -0,0 +1,7 @@ +import enum + + +class InventoryType(str, enum.Enum): + regular = "regular" + bundle = "bundle" + bundle_item = "bundle_item" diff --git a/barker/barker/models/modifier_category.py b/barker/barker/models/modifier_category.py index e3dc943..db36986 100644 --- a/barker/barker/models/modifier_category.py +++ b/barker/barker/models/modifier_category.py @@ -28,7 +28,7 @@ class ModifierCategory: sort_order: Mapped[int] = mapped_column(Integer, nullable=False) products: Mapped[list[Product]] = relationship( - secondary=ModifierCategoryProduct.__table__, + secondary=ModifierCategoryProduct.__table__, # type: ignore[attr-defined] back_populates="modifier_categories", ) modifiers: Mapped[list[Modifier]] = relationship(back_populates="modifier_category") diff --git a/barker/barker/models/permission.py b/barker/barker/models/permission.py index bdff933..098931d 100644 --- a/barker/barker/models/permission.py +++ b/barker/barker/models/permission.py @@ -23,7 +23,7 @@ class Permission: name: Mapped[str] = mapped_column(Text, unique=True, nullable=False) roles: Mapped[list[Role]] = relationship( - secondary=RolePermission.__table__, + secondary=RolePermission.__table__, # type: ignore[attr-defined] back_populates="permissions", ) diff --git a/barker/barker/models/product.py b/barker/barker/models/product.py index 1fd8059..d00fed2 100644 --- a/barker/barker/models/product.py +++ b/barker/barker/models/product.py @@ -26,7 +26,7 @@ class Product: skus: Mapped[list[StockKeepingUnit]] = relationship("StockKeepingUnit", back_populates="product") modifier_categories: Mapped[list[ModifierCategory]] = relationship( "ModifierCategory", - secondary=ModifierCategoryProduct.__table__, + secondary=ModifierCategoryProduct.__table__, # type: ignore[attr-defined] order_by="ModifierCategory.sort_order", back_populates="products", ) diff --git a/barker/barker/models/product_version.py b/barker/barker/models/product_version.py index bb4ba19..dc11034 100644 --- a/barker/barker/models/product_version.py +++ b/barker/barker/models/product_version.py @@ -57,20 +57,27 @@ class ProductVersion: def __init__( self, - product_id: uuid.UUID, + product_id: uuid.UUID | None, name: str = "", fraction_units: str = "", sale_category_id: uuid.UUID | None = None, valid_from: date | None = None, valid_till: date | None = None, + product: Product | None = None, + sale_category: SaleCategory | None = None, id_: uuid.UUID | None = None, ): - self.product_id = product_id + if product_id is not None: + self.product_id = product_id self.name = name self.fraction_units = fraction_units + if product is not None: + self.product = product if sale_category_id is not None: self.sale_category_id = sale_category_id self.valid_from = valid_from self.valid_till = valid_till + if sale_category is not None: + self.sale_category = sale_category if id_ is not None: self.id = id_ diff --git a/barker/barker/models/role.py b/barker/barker/models/role.py index abc8f72..ffafb02 100644 --- a/barker/barker/models/role.py +++ b/barker/barker/models/role.py @@ -24,7 +24,7 @@ class Role: permissions: Mapped[list[Permission]] = relationship( "Permission", - secondary=RolePermission.__table__, + secondary=RolePermission.__table__, # type: ignore[attr-defined] back_populates="roles", ) diff --git a/barker/barker/models/stock_keeping_unit.py b/barker/barker/models/stock_keeping_unit.py index 94e1372..f79ea50 100644 --- a/barker/barker/models/stock_keeping_unit.py +++ b/barker/barker/models/stock_keeping_unit.py @@ -11,6 +11,7 @@ from ..db.base_class import reg if TYPE_CHECKING: + from .bundle_item import BundleItem from .inventory import Inventory from .product import Product from .sku_version import SkuVersion @@ -25,21 +26,27 @@ class StockKeepingUnit: ) product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) is_not_available: Mapped[bool] = mapped_column(Boolean, nullable=False) + is_bundle: Mapped[bool] = mapped_column(Boolean, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False) product: Mapped[Product] = relationship("Product", back_populates="skus") inventories: Mapped[list[Inventory]] = relationship(back_populates="sku") versions: Mapped[list[SkuVersion]] = relationship(back_populates="sku") + bundle_items: Mapped[list[BundleItem]] = relationship( + "BundleItem", back_populates="bundle", foreign_keys="BundleItem.bundle_id" + ) def __init__( self, is_not_available: bool = False, + is_bundle: bool = False, sort_order: int = 0, product_id: uuid.UUID | None = None, product: Product | None = None, id_: uuid.UUID | None = None, ) -> None: self.is_not_available = is_not_available + self.is_bundle = is_bundle self.sort_order = sort_order if product_id is not None: self.product_id = product_id diff --git a/barker/barker/models/user.py b/barker/barker/models/user.py index 5fd393c..1166082 100644 --- a/barker/barker/models/user.py +++ b/barker/barker/models/user.py @@ -28,7 +28,7 @@ class User: roles: Mapped[list[Role]] = relationship( "Role", - secondary=UserRole.__table__, + secondary=UserRole.__table__, # type: ignore[attr-defined] order_by="Role.name", ) login_history: Mapped[list[LoginHistory]] = relationship(order_by=desc(LoginHistory.date), back_populates="user") diff --git a/barker/barker/models/voucher.py b/barker/barker/models/voucher.py index 2f8994e..0a6b800 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/printing/__init__.py b/barker/barker/printing/__init__.py index 68a7cb1..bebb388 100644 --- a/barker/barker/printing/__init__.py +++ b/barker/barker/printing/__init__.py @@ -1,59 +1,20 @@ -import locale -import uuid - -from datetime import date from decimal import Decimal -from sqlalchemy import and_, or_, select -from sqlalchemy.orm import Session, contains_eager - -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 currency_format(amount: Decimal | int) -> str: - return locale.currency(amount, symbol=False, grouping=True) + return format_indian(amount) def format_no_decimals(amount: Decimal | int) -> str: - return locale.format_string("%d", amount, grouping=True, monetary=True) + return format_indian(amount).split(".")[0] -def get_product_name(sku_id: uuid.UUID, date_: date, db: Session) -> str: - item = get_product(sku_id, date_, db) - return f"{item.sku.product.versions[0].name} ({item.units})" - - -def get_product(sku_id: uuid.UUID, date_: date, db: Session) -> SkuVersion: - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) - return ( - db.execute( - select(SkuVersion) - .join(StockKeepingUnit, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .where(SkuVersion.sku_id == sku_id) - .options( - contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions) - ) - ) - .unique() - .scalar_one() - ) +def format_indian(amount: Decimal | int) -> str: + s = f"{abs(amount):.2f}" + integer, fraction = s.split(".") + remainder = integer[-3:] + integer = integer[:-3] + while integer: + remainder = integer[-2:] + "," + remainder + integer = integer[:-2] + return ("-" if amount < 0 else "") + remainder + "." + fraction diff --git a/barker/barker/printing/bill.py b/barker/barker/printing/bill.py index 1ede7b1..dad83a2 100644 --- a/barker/barker/printing/bill.py +++ b/barker/barker/printing/bill.py @@ -1,5 +1,4 @@ import asyncio -import locale import re import uuid @@ -7,7 +6,7 @@ from datetime import timedelta from decimal import Decimal from arq import ArqRedis, create_pool -from sqlalchemy import select +from sqlalchemy import Date, func, select from sqlalchemy.orm import Session from ..core.arq import settings as redis_settings @@ -15,16 +14,22 @@ from ..core.config import settings from ..models.bill import Bill from ..models.db_setting import DbSetting from ..models.inventory import Inventory +from ..models.inventory_type import InventoryType +from ..models.kot import Kot from ..models.printer import Printer from ..models.section_printer import SectionPrinter from ..models.voucher import Voucher from ..models.voucher_type import VoucherType -from . import currency_format, format_no_decimals, get_product_name +from ..routers import _pv_onclause, _sv_onclause +from ..routers.voucher import get_voucher +from . import currency_format, format_no_decimals def print_bill(voucher_id: uuid.UUID, db: Session) -> None: - locale.setlocale(locale.LC_MONETARY, "en_IN") - voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == voucher_id)).scalar_one() + day = func.cast( + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + voucher: Voucher = get_voucher(voucher_id, db, _pv_onclause(day), _sv_onclause(day)) printer = db.execute( select(Printer) @@ -38,7 +43,8 @@ def print_bill(voucher_id: uuid.UUID, db: Session) -> None: for regime_id, inventories in regimes: taxes_dict: dict[str, Decimal] = {} for i in inventories: - get_tax_item(i.tax.id, i.tax.name, i.tax_amount, taxes_dict) + if i.type_ != InventoryType.bundle_item: + get_tax_item(i.tax.id, i.tax.name, i.tax_amount, taxes_dict) taxes_list = [(t, a) for t, a in taxes_dict.items() if a != 0] bill_text = design_bill(voucher, regime_id, inventories, taxes_list, db) bills.append(bill_text) @@ -62,12 +68,16 @@ def print_bill(voucher_id: uuid.UUID, db: Session) -> None: def get_inventories(voucher: Voucher) -> list[Inventory]: - items_dict: dict[tuple[uuid.UUID, bool, Decimal, frozenset[uuid.UUID]], Inventory] = {} + items_dict: dict[ + tuple[uuid.UUID, bool, Decimal, InventoryType, uuid.UUID | None, frozenset[uuid.UUID]], Inventory + ] = {} for i in [i for k in voucher.kots for i in k.inventories]: key = ( i.sku_id, i.is_happy_hour, i.discount, + i.type_, + None if i.parent is None else i.parent.sku_id, frozenset(m.modifier_id for m in i.modifiers if m.modifier.show_in_bill), ) if key in items_dict: @@ -85,6 +95,8 @@ def get_inventories(voucher: Voucher) -> list[Inventory]: kot_id=i.kot_id, tax_id=i.tax_id, sort_order=i.sort_order, + parent_id=i.parent_id, + type_=i.type_, ) return list([item for item in items_dict.values() if item.quantity != 0]) @@ -98,9 +110,10 @@ def get_regimes( ] items_dict: dict[int, list[Inventory]] = {} - + previous_regime_id: int = inventories[0].tax.regime_id for inv in inventories: - key = inv.tax.regime_id + key = previous_regime_id if inv.type_ == InventoryType.bundle_item else inv.tax.regime_id + previous_regime_id = key if key not in items_dict: items_dict[key] = [] items_dict[key].append(inv) @@ -142,7 +155,6 @@ def design_bill( s += "\n\r" + bill_object.regime.header # Products voucher_date = voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES) - product_date = (voucher_date - timedelta(minutes=settings.NEW_DAY_OFFSET_MINUTES)).date() bill_number = f"{bill_object.regime.prefix}-{bill_object.bill_number}" s += "\n\r" + f"Bill No: {bill_number:>12} {voucher_date:%d-%b-%Y %H:%M}" @@ -151,33 +163,44 @@ def design_bill( s += "\n\r" + "Qty. Particulars Price Amount" s += "\n\r" + "-" * 42 for inv in inventories: - name = get_product_name(inv.sku_id, product_date, db) - name = "H H " + name if inv.is_happy_hour else name - s += ( - f"\n\r" - f"{inv.quantity: >5.2f} {name:<22.22} {format_no_decimals(inv.price): >6}" - f" {format_no_decimals(inv.price * inv.quantity): >6}" - ) + name = ( + "H H " if inv.is_happy_hour else "" + ) + f"{inv.sku.product.versions[0].name} ({inv.sku.versions[0].units})" + if inv.parent_id: + info = f" ({inv.quantity:f}) {name}" + s += "\n\r" + info[:42] + else: + s += ( + f"\n\r" + f"{inv.quantity: >5.2f} {name:<22.22} {format_no_decimals(inv.price): >6}" + f" {format_no_decimals(inv.price * inv.quantity): >6}" + ) for m in [m for m in inv.modifiers if m.modifier.show_in_bill]: s += f"\n\r -- {m.modifier.name: <38.38}" s += "\n\r" + "------------------------------------------" # Totals - amount = sum(round(i.quantity * i.price, 2) for i in inventories) + amount = sum(round(i.quantity * i.price, 2) for i in inventories if i.type_ != InventoryType.bundle_item) s += f"\n\r{'Subtotal :': >32} {currency_format(amount): >9}" - amount = sum(round(i.quantity * i.price, 2) for i in inventories if i.is_happy_hour) + amount = sum( + round(i.quantity * i.price, 2) for i in inventories if i.is_happy_hour and i.type_ != InventoryType.bundle_item + ) if amount != 0: s += f"\n\r{'Happy Hour Discount :': >32} {currency_format(amount): >9}" - amount = sum(round(i.quantity * i.effective_price * i.discount, 2) for i in inventories) + amount = sum( + round(i.quantity * i.effective_price * i.discount, 2) + for i in inventories + if i.type_ != InventoryType.bundle_item + ) if amount != 0: s += f"\n\r{'Discount :': >32} {currency_format(amount): >9}" for t in taxes: s += f"\n\r{t[0]: >30} : {currency_format(t[1]): >9}" - s += f"\n\r{'Total Amount :': >32} {currency_format(round(sum([i.amount for i in inventories]))): >9}" + s += f"\n\r{'Total Amount :': >32} {currency_format(round(sum([i.amount for i in inventories if i.type_ != InventoryType.bundle_item]))): >9}" s += "\n\r" + "-" * 42 if voucher.narration: diff --git a/barker/barker/printing/cashier_report.py b/barker/barker/printing/cashier_report.py index 4060da5..27a259a 100644 --- a/barker/barker/printing/cashier_report.py +++ b/barker/barker/printing/cashier_report.py @@ -1,5 +1,4 @@ import asyncio -import locale import uuid from datetime import datetime, timedelta @@ -18,7 +17,6 @@ from . import currency_format def print_cashier_report(report: CashierReport, device_id: uuid.UUID, db: Session) -> None: - locale.setlocale(locale.LC_MONETARY, "en_IN") summary = design_cashier_report_summary(report) details = design_cashier_report_details(report) section_id = db.execute(select(Device.section_id).where(Device.id == device_id)).scalar_one() diff --git a/barker/barker/printing/discount_report.py b/barker/barker/printing/discount_report.py index 0e5e2f3..24bf4ea 100644 --- a/barker/barker/printing/discount_report.py +++ b/barker/barker/printing/discount_report.py @@ -1,5 +1,4 @@ import asyncio -import locale import uuid from arq import ArqRedis, create_pool @@ -15,7 +14,6 @@ from . import currency_format def print_discount_report(report: DiscountReport, device_id: uuid.UUID, db: Session) -> None: - locale.setlocale(locale.LC_MONETARY, "en_IN") data = design_discount_report(report) section_id = db.execute(select(Device.section_id).where(Device.id == device_id)).scalar_one() printer = db.execute( diff --git a/barker/barker/printing/kot.py b/barker/barker/printing/kot.py index 7f64d67..e38f8a0 100644 --- a/barker/barker/printing/kot.py +++ b/barker/barker/printing/kot.py @@ -5,7 +5,7 @@ from datetime import timedelta from typing import Any from arq import ArqRedis, create_pool -from sqlalchemy import or_, select +from sqlalchemy import Date, func, or_, select from sqlalchemy.orm import Session from ..core.arq import settings as redis_settings @@ -15,14 +15,12 @@ from ..models.kot import Kot from ..models.printer import Printer from ..models.section_printer import SectionPrinter from ..models.voucher import Voucher -from . import get_product, get_product_name +from ..routers import _pv_onclause, _sv_onclause +from ..routers.voucher import get_voucher def design_kot(voucher: Voucher, kot: Kot, items: list[Inventory], copy_number: int, db: Session) -> str: date_ = kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES) - product_date = ( - voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES) - ).date() s = ( "KOT / BOT".center(42) + "\n\r" @@ -41,9 +39,13 @@ def design_kot(voucher: Voucher, kot: Kot, items: list[Inventory], copy_number: + "".ljust(42, "-") ) for item in items: - name = get_product_name(item.sku_id, product_date, db) - name = "H H " + name if item.is_happy_hour else name - s += "\n\r" + f"{item.quantity:6.2} x {name:<33}" + name = ( + "H H " if item.is_happy_hour else "" + ) + f"{item.sku.product.versions[0].name} ({item.sku.versions[0].units})" + if item.parent_id: + s += "\n\r" + f"{item.quantity:6.2f} #= {name:<32}" + else: + s += "\n\r" + f"{item.quantity:6.2f} x {name:<33}" for m in item.modifiers: s += "\n\r" + f" --- {m.modifier.name:<32}" s += "\n\r" + "".ljust(42, "-") @@ -51,14 +53,14 @@ def design_kot(voucher: Voucher, kot: Kot, items: list[Inventory], copy_number: def print_kot(voucher_id: uuid.UUID, db: Session) -> None: - voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == voucher_id)).scalar_one() + day = func.cast( + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + voucher: Voucher = get_voucher(voucher_id, db, _pv_onclause(day), _sv_onclause(day)) my_hash: dict[tuple[uuid.UUID, int], tuple[Printer, list[Any]]] = {} kot: Kot = voucher.kots[-1] - product_date = ( - voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES) - ).date() for item in kot.inventories: - product = get_product(item.sku_id, product_date, db) + sku = item.sku row = db.execute( select(Printer, SectionPrinter.copies) @@ -66,7 +68,7 @@ def print_kot(voucher_id: uuid.UUID, db: Session) -> None: .where(SectionPrinter.section_id == voucher.food_table.section_id) .where( or_( - SectionPrinter.sale_category_id == product.sku.product.versions[0].sale_category_id, + SectionPrinter.sale_category_id == sku.product.versions[0].sale_category_id, SectionPrinter.sale_category_id == None, # noqa: E711 ) ) diff --git a/barker/barker/printing/product_sale_report.py b/barker/barker/printing/product_sale_report.py index adb1dc6..0a43bde 100644 --- a/barker/barker/printing/product_sale_report.py +++ b/barker/barker/printing/product_sale_report.py @@ -1,5 +1,4 @@ import asyncio -import locale import uuid from datetime import datetime, timedelta @@ -17,7 +16,6 @@ from ..schemas.product_sale_report import ProductSaleReport def print_product_sale_report(report: ProductSaleReport, device_id: uuid.UUID, db: Session) -> None: - locale.setlocale(locale.LC_MONETARY, "en_IN") data = design_product_sale_report(report) section_id = db.execute(select(Device.section_id).where(Device.id == device_id)).scalar_one() printer = db.execute( diff --git a/barker/barker/printing/sale_report.py b/barker/barker/printing/sale_report.py index 40ef881..91a5b42 100644 --- a/barker/barker/printing/sale_report.py +++ b/barker/barker/printing/sale_report.py @@ -1,5 +1,4 @@ import asyncio -import locale import uuid from datetime import datetime, timedelta @@ -18,7 +17,6 @@ from . import currency_format def print_sale_report(report: SaleReport, device_id: uuid.UUID, db: Session) -> None: - locale.setlocale(locale.LC_MONETARY, "en_IN") data = design_sale_report(report) section_id = db.execute(select(Device.section_id).where(Device.id == device_id)).scalar_one() printer = db.execute( diff --git a/barker/barker/routers/__init__.py b/barker/barker/routers/__init__.py index c8de974..5f64f11 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -1,6 +1,14 @@ from datetime import UTC, date, datetime, timedelta +from typing import Any + +from sqlalchemy import ColumnElement, 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: @@ -32,3 +40,46 @@ def dates_overlap(start1: date | None, end1: date | None, start2: date | None, e if end2 is None: end2 = date.max return start1 <= end2 and start2 <= end1 + + +def _pv_active(date_: date | Label[Any]) -> ColumnElement[bool]: + return and_( + or_(ProductVersion.valid_from == None, ProductVersion.valid_from <= date_), # noqa: E711 + or_(ProductVersion.valid_till == None, ProductVersion.valid_till >= date_), # noqa: E711 + ) + + +def _sv_active(date_: date | Label[Any]) -> ColumnElement[bool]: + return and_( + or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 + or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 + ) + + +def _bundle_active(date_: date) -> ColumnElement[bool]: + 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]) -> ColumnElement[bool]: + return and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + + +def _sv_onclause(date_: date | Label[Any]) -> ColumnElement[bool]: + return and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 + or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 + ) diff --git a/barker/barker/routers/bundle.py b/barker/barker/routers/bundle.py new file mode 100644 index 0000000..0c15324 --- /dev/null +++ b/barker/barker/routers/bundle.py @@ -0,0 +1,529 @@ +import uuid + +from collections.abc import Sequence +from datetime import date, timedelta +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Security, status +from sqlalchemy import Date, func, or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import contains_eager + +from ..core.config import settings +from ..core.security import get_current_active_user as get_user +from ..db.session import SessionFuture +from ..models.bundle_item import BundleItem as BundleItemModel +from ..models.inventory import Inventory +from ..models.kot import Kot +from ..models.product import Product +from ..models.product_version import ProductVersion +from ..models.sku_version import SkuVersion +from ..models.stock_keeping_unit import StockKeepingUnit +from ..models.voucher import Voucher +from ..schemas import bundle as schemas +from ..schemas.menu_category import MenuCategoryLink +from ..schemas.sale_category import SaleCategoryLink +from ..schemas.user_token import UserToken +from . import _pv_onclause, _sv_onclause, effective_date + + +router = APIRouter() + +FRACTION_1 = Decimal("1") +YIELD_1 = Decimal("1") +COST_0 = Decimal("0") + + +# ---------- helpers ---------- + + +def bundle_blank() -> schemas.BundleBlank: + return schemas.BundleBlank( + name="", + units="", + sale_price=Decimal("0.00"), + sale_category=None, + has_happy_hour=False, + is_not_available=False, + sort_order=0, + items=[], + ) + + +def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemModel]) -> schemas.Bundle: + sku = sv.sku + return schemas.Bundle( + id_=sku.id, + version_id=sv.id, + name=pv.name, + units=pv.fraction_units, + sale_price=sv.sale_price, + sale_category=SaleCategoryLink(id_=pv.sale_category_id, name=pv.sale_category.name), + has_happy_hour=sv.has_happy_hour, + is_not_available=sku.is_not_available, + sort_order=sku.sort_order, + menu_category=MenuCategoryLink( + id_=sv.menu_category_id, + name=sv.menu_category.name, + skus=[], + ) + if sv.menu_category is not None + else None, + items=[ + schemas.BundleItem( + id_=bi.id, + name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})", + item_id=bi.item_id, + sale_price=bi.sale_price, + quantity=bi.quantity, + print_in_bill=bi.print_in_bill, + ) + for bi in items + ], + ) + + +# ---------- routes ---------- + + +@router.post("", response_model=None) +def save( + data: schemas.BundleIn, + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + try: + with SessionFuture() as db: + if not data.items: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Not enough bundle items.", + ) + sale_price = round( + sum((round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), start=Decimal(0)), 2 + ) + + product = Product(sort_order=data.sort_order) + db.add(product) + db.flush() + + pv = ProductVersion( + product_id=None, + product=product, + name=data.name, + fraction_units=data.units, # per your rule + sale_category_id=data.sale_category.id_, + sale_category=None, + valid_from=date_, + valid_till=None, + ) + db.add(pv) + + sku = StockKeepingUnit( + product=product, + is_not_available=data.is_not_available, + is_bundle=True, + sort_order=data.sort_order, + ) + db.add(sku) + db.flush() + + sv = SkuVersion( + sku_id=sku.id, + units=data.units, + fraction=FRACTION_1, + product_yield=YIELD_1, + cost_price=COST_0, + sale_price=sale_price, + has_happy_hour=data.has_happy_hour, + menu_category_id=data.menu_category.id_, + valid_from=date_, + valid_till=None, + ) + db.add(sv) + + # bundle items + for it in data.items: + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + quantity=round(it.quantity, 5), + sale_price=round(it.sale_price, 2), + print_in_bill=it.print_in_bill, + valid_from=date_, + valid_till=None, + ) + ) + + db.commit() + + except SQLAlchemyError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.put("/{id_}", response_model=None) +def update_route( + id_: uuid.UUID, # bundle header SKU id + data: schemas.BundleIn, + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + try: + with SessionFuture() as db: + if not data.items: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Not enough bundle items.", + ) + sale_price = round( + sum((round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), start=Decimal(0)), 2 + ) + # Load header SKU + active header SkuVersion + menu category + product + active ProductVersion + sv: SkuVersion | None = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .join(SkuVersion.menu_category) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + contains_eager(SkuVersion.menu_category), + ) + ) + .unique() + .scalars() + .one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + sku = sv.sku + product = sku.product + pv = product.versions[0] + pv_changed = pv.name != data.name or pv.sale_category_id != data.sale_category.id_ + + sku.is_not_available = data.is_not_available + sku.sort_order = data.sort_order + product.sort_order = data.sort_order + + if pv_changed: + if pv.valid_from == date_: + pv.name = data.name + pv.sale_category_id = data.sale_category.id_ + else: + pv.valid_till = date_ - timedelta(days=1) + db.add( + ProductVersion( + product_id=product.id, + product=product, + name=data.name, + fraction_units=pv.fraction_units, + sale_category_id=data.sale_category.id_, + valid_from=date_, + valid_till=None, + ) + ) + sv_changed = ( + sv.units != data.units + or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price + or sv.has_happy_hour != data.has_happy_hour + or sv.menu_category_id != data.menu_category.id_ + ) + if sv_changed: + if sv.valid_from == date_: + sv.units = data.units + sv.sale_price = sale_price + sv.has_happy_hour = data.has_happy_hour + sv.menu_category_id = data.menu_category.id_ + + # # enforce bundle constants + # sv.fraction = FRACTION_1 + # sv.product_yield = YIELD_1 + # sv.cost_price = COST_0 + else: + sv.valid_till = date_ - timedelta(days=1) + db.add( + SkuVersion( + sku_id=sku.id, + units=data.units, + fraction=FRACTION_1, + product_yield=YIELD_1, + cost_price=COST_0, + sale_price=sale_price, + has_happy_hour=data.has_happy_hour, + menu_category_id=data.menu_category.id_, + valid_from=date_, + valid_till=None, + ) + ) + + # ---- Bundle items replace/update + existing = db.execute(select(BundleItemModel).where(BundleItemModel.bundle_id == sku.id)).scalars().all() + existing_by_item = {x.item_id: x for x in existing} + incoming_ids = {x.item_id for x in data.items} + + existing_by_item = {x.item_id: x for x in existing} + + incoming_ids = {x.item_id for x in data.items} + + # delete removed + for ex_d in existing: + if ex_d.item_id not in incoming_ids: + if ex_d.valid_from == date_: + db.delete(ex_d) + else: + ex_d.valid_till = date_ - timedelta(days=1) + + # add/update + for it in data.items: + ex = existing_by_item.get(it.item_id) + if ex is None: + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + sale_price=round(it.sale_price, 2), + quantity=round(it.quantity, 5), + print_in_bill=it.print_in_bill, + valid_from=date_, + valid_till=None, + ) + ) + elif ex.valid_from == date_: + ex.sale_price = round(it.sale_price, 2) + ex.quantity = round(it.quantity, 5) + else: + ex.valid_till = date_ - timedelta(days=1) + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + sale_price=round(it.sale_price, 2), + quantity=round(it.quantity, 5), + print_in_bill=it.print_in_bill, + valid_from=date_, + valid_till=None, + ) + ) + + db.commit() + + except SQLAlchemyError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.get("", response_model=schemas.BundleBlank) +def show_blank( + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.BundleBlank: + return bundle_blank() + + +@router.get("/list", response_model=list[schemas.Bundle]) +def show_list( + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> list[schemas.Bundle]: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + + rows = ( + db.execute( + select(SkuVersion) + # .select_from(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(SkuVersion.menu_category) + .where(StockKeepingUnit.is_bundle == True) # noqa: E712 + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product), + contains_eager(SkuVersion.menu_category), + ) + .order_by(StockKeepingUnit.sort_order, SkuVersion.units) + ) + .unique() + .scalars() + .all() + ) + + out: list[schemas.Bundle] = [] + for sv in rows: + pv = sv.sku.product.versions[0] + items = ( + db.execute( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(ProductVersion.sale_category) + .where( + BundleItemModel.bundle_id == sv.sku_id, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), + ) + ) + .unique() + .scalars() + .all() + ) + out.append(_bundle_info(pv=pv, sv=sv, items=items)) + return out + + +@router.get("/{id_}", response_model=schemas.Bundle) +def show_id( + id_: uuid.UUID, # bundle header SKU id + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.Bundle: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + + sv = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(SkuVersion.menu_category) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product), + contains_eager(SkuVersion.menu_category), + ) + ) + .unique() + .scalar_one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + pv = sv.sku.product.versions[0] + + items = ( + db.execute( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(ProductVersion.sale_category) + .where( + BundleItemModel.bundle_id == sv.sku_id, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), + ) + ) + .unique() + .scalars() + .all() + ) + return _bundle_info(pv=pv, sv=sv, items=items) + + +@router.delete("/{id_}", response_model=None) +def delete_route( + id_: uuid.UUID, # bundle header SKU id + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + day = func.cast( + Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + billed = db.execute( + select(func.count(Inventory.id)) + .join(Inventory.kot) + .join(Kot.voucher) + .where(Inventory.sku_id == id_, day >= date_) + ).scalar_one() + if billed > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The cannot delete this product as it was billed", + ) + + sv = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options(contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product)) + ) + .unique() + .scalar_one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + pv = sv.sku.product.versions[0] + + # close/delete sku version + if sv.valid_from == date_: + db.delete(sv) + else: + sv.valid_till = date_ - timedelta(days=1) + + # close/delete product version + if pv.valid_from == date_: + db.delete(pv) + else: + pv.valid_till = date_ - timedelta(days=1) + + items = ( + db.execute( + select(BundleItemModel).where( + BundleItemModel.bundle_id == id_, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + ) + .scalars() + .all() + ) + for bi in items: + if bi.valid_from == date_: + db.delete(bi) + else: + bi.valid_till = date_ - timedelta(days=1) + + db.commit() diff --git a/barker/barker/routers/device.py b/barker/barker/routers/device.py index 0b9fc12..3284428 100644 --- a/barker/barker/routers/device.py +++ b/barker/barker/routers/device.py @@ -9,6 +9,7 @@ from ..db.session import SessionFuture from ..models.device import Device from ..models.login_history import LoginHistory from ..schemas import device as schemas +from ..schemas.section import SectionLink from ..schemas.user_token import UserToken @@ -74,7 +75,7 @@ def device_info(item: Device) -> schemas.Device: id_=item.id, name=item.name, enabled=item.enabled, - section=schemas.SectionLink(id_=item.section_id, name=item.section.name), + section=SectionLink(id_=item.section_id, name=item.section.name), creation_date=item.creation_date, last_user=item.login_history[0].user.name if len(item.login_history) else "Never", last_date=item.login_history[0].date if len(item.login_history) else None, diff --git a/barker/barker/routers/header_footer.py b/barker/barker/routers/header_footer.py index fed4786..5bca8de 100644 --- a/barker/barker/routers/header_footer.py +++ b/barker/barker/routers/header_footer.py @@ -5,29 +5,29 @@ from sqlalchemy.exc import SQLAlchemyError from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.db_setting import DbSetting -from ..schemas import header_footer as schemas +from ..schemas.header_footer import HeaderFooter from ..schemas.user_token import UserToken router = APIRouter() -@router.get("") +@router.get("", response_model=list[HeaderFooter]) def get( user: UserToken = Security(get_user, scopes=["owner"]), -): +) -> list[HeaderFooter]: with SessionFuture() as db: return [ - {"id": item.id, "name": item.name, "text": item.data["Text"]} + HeaderFooter(id_=item.id, name=item.name, text=item.data["Text"]) for item in db.execute(select(DbSetting).where(DbSetting.name.in_(["Header", "Footer"]))).scalars().all() ] -@router.post("") +@router.post("", response_model=list[HeaderFooter]) def save( - data: schemas.HeaderFooter, + data: HeaderFooter, user: UserToken = Security(get_user, scopes=["owner"]), -): +) -> list[HeaderFooter]: try: with SessionFuture() as db: item: DbSetting = db.execute( @@ -36,7 +36,7 @@ def save( item.data = {"Text": data.text} db.commit() return [ - {"id": item.id, "name": item.name, "text": item.data["Text"]} + HeaderFooter(id_=item.id, name=item.name, text=item.data["Text"]) for item in db.execute(select(DbSetting).where(DbSetting.name.in_(["Header", "Footer"]))) .scalars() .all() diff --git a/barker/barker/routers/health.py b/barker/barker/routers/health.py index 4031f19..7ea562a 100644 --- a/barker/barker/routers/health.py +++ b/barker/barker/routers/health.py @@ -2,16 +2,17 @@ from fastapi import APIRouter, HTTPException from sqlalchemy import text from ..db.session import SessionFuture +from ..schemas.health_response import HealthResponse router = APIRouter() -@router.get("") -def health_check(): +@router.get("", response_model=HealthResponse) +def health_check() -> HealthResponse: try: with SessionFuture() as db: # lightweight DB check db.execute(text("SELECT 1")) - return {"status": "ok", "database": "connected"} + return HealthResponse(status="ok", database="connected") except Exception as e: raise HTTPException(status_code=500, detail={"status": "error", "database": str(e)}) from e diff --git a/barker/barker/routers/modifier.py b/barker/barker/routers/modifier.py index 0c4b38f..af34d15 100644 --- a/barker/barker/routers/modifier.py +++ b/barker/barker/routers/modifier.py @@ -10,6 +10,7 @@ from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.modifier import Modifier from ..schemas import modifier as schemas +from ..schemas.modifier_category import ModifierCategoryLink from ..schemas.user_token import UserToken @@ -111,7 +112,7 @@ def modifier_info(item: Modifier) -> schemas.Modifier: show_in_bill=item.show_in_bill, price=item.price, is_active=item.is_active, - modifier_category=schemas.ModifierCategoryLink( + modifier_category=ModifierCategoryLink( id_=item.modifier_category.id, name=item.modifier_category.name, ), diff --git a/barker/barker/routers/modifier_category.py b/barker/barker/routers/modifier_category.py index dca0745..92a2277 100644 --- a/barker/barker/routers/modifier_category.py +++ b/barker/barker/routers/modifier_category.py @@ -18,7 +18,8 @@ from ..models.sku_version import SkuVersion from ..models.stock_keeping_unit import StockKeepingUnit from ..schemas import modifier_category as schemas from ..schemas.menu_category import MenuCategoryLink -from ..schemas.modifier_category_for_product import ModifierCategoryForProduct, ModifierLink +from ..schemas.modifier import ModifierLink +from ..schemas.modifier_category_for_product import ModifierCategoryForProduct from ..schemas.product_link import ProductLink from ..schemas.product_link import ProductLink as ProductLinkSchema from ..schemas.user_token import UserToken @@ -172,7 +173,7 @@ def show_list( is_active=item.is_active, sort_order=item.sort_order, menu_categories=[ - schemas.MenuCategoryLink( + MenuCategoryLink( id_=mc.id, name=mc.name, enabled=reduce(lambda x, y: x and (y.sku.product_id in products), mc.skus, True), @@ -190,7 +191,7 @@ def show_list( return modifier_categories -@router.get("/for-product/{sku_id}") +@router.get("/for-sku/{sku_id}") def for_product( sku_id: uuid.UUID, date_: date = Depends(effective_date), @@ -283,7 +284,7 @@ def modifier_category_info(item: ModifierCategory, date_: date, db: Session) -> maximum=item.maximum, is_active=item.is_active, menu_categories=[ - schemas.MenuCategoryLink( + MenuCategoryLink( id_=mc.id, name=mc.name, skus=[ @@ -354,7 +355,7 @@ def modifier_category_blank(date_: date, db: Session) -> schemas.ModifierCategor maximum=0, is_active=True, menu_categories=[ - schemas.MenuCategoryLink( + MenuCategoryLink( id_=mc.id, name=mc.name, skus=[ diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index 1da2429..1e4408f 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,11 +27,12 @@ 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.stock_keeping_unit import StockKeepingUnit as StockKeepingUnitSchema from ..schemas.tax import TaxLink from ..schemas.user_token import UserToken -from . import effective_date +from . import _pv_onclause, _sv_onclause, effective_date router = APIRouter() @@ -168,22 +171,8 @@ def update_route( ) -> None: try: with SessionFuture() as db: - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) + product_version_onclause = _pv_onclause(date_) + sku_version_onclause = _sv_onclause(date_) version: ProductVersion = db.execute( select(ProductVersion) .join(Product, onclause=product_version_onclause) @@ -446,46 +435,61 @@ def product_list(date_: date, db: Session) -> list[schemas.Product]: # ] -@router.get("/query") +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, mc: uuid.UUID | None = None, sc: uuid.UUID | None = None, date_: date = Depends(effective_date), current_user: UserToken = Depends(get_user), -): - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) +) -> list[ProductQuery]: + product_version_onclause = _pv_onclause(date_) + sku_version_onclause = _sv_onclause(date_) print(f"Fetching products for MenuCategory: {mc}, SaleCategory: {sc}, Date: {date_}") list_: list[ProductQuery] = [] query = ( select(SkuVersion) + .join(SkuVersion.menu_category) .join(StockKeepingUnit, onclause=sku_version_onclause) .join(StockKeepingUnit.product) .join(ProductVersion, onclause=product_version_onclause) - # .join(SkuVersion.menu_category) - # .join(ProductVersion.sale_category) + .join(ProductVersion.sale_category) + .join(SaleCategory.tax) .options( - contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions) + contains_eager(SkuVersion.menu_category), + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category) + .contains_eager(SaleCategory.tax), ) ) - if mc is not None: - query = query.join(SkuVersion.menu_category) - if sc is not None: - query = query.join(SkuVersion.menu_category).join(ProductVersion.sale_category).join(SaleCategory.tax) if mc is not None: query = query.where(SkuVersion.menu_category_id == mc).order_by( Product.sort_order, ProductVersion.name, StockKeepingUnit.sort_order, SkuVersion.units @@ -498,72 +502,23 @@ def show_term( StockKeepingUnit.sort_order, SkuVersion.units, ) - - if mc is not None: - query = query.options( - contains_eager(SkuVersion.menu_category), - ) - - if sc is not None: - query = query.options( - contains_eager(ProductVersion.sale_category).contains_eager(SaleCategory.tax), - ) + if q is not None: + for name in q.split(): + query = query.where(or_(ProductVersion.name.ilike(f"%{name}%"), SkuVersion.units.ilike(f"%{name}%"))) with SessionFuture() as db: - for item in db.execute(query).unique().scalars().all(): - if sc is not None: - list_.append( - ProductQuery( - id_=item.sku_id, - name=f"{item.sku.product.versions[0].name} ({item.units})", - menu_category=MenuCategoryLink( - id_=item.menu_category_id, name=item.menu_category.name, skus=[] - ), - price=item.sale_price, - has_happy_hour=item.has_happy_hour, - is_not_available=item.sku.is_not_available, - ) - ) - if mc is not None: - list_.append(query_product_info(item, False)) - if item.has_happy_hour: - list_.append(query_product_info(item, True)) + 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, @@ -610,10 +565,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, @@ -627,6 +587,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 + ], ) @@ -636,12 +611,12 @@ def product_info(version: ProductVersion) -> schemas.Product: version_id=version.id, name=version.name, fraction_units=version.fraction_units, - sale_category=schemas.SaleCategoryLink( + sale_category=SaleCategoryLink( id_=version.sale_category_id, name=version.sale_category.name, ), skus=[ - schemas.StockKeepingUnit( + StockKeepingUnitSchema( id_=sku.id, units=sku_version.units, fraction=sku_version.fraction, diff --git a/barker/barker/routers/reports/beer_sale_report.py b/barker/barker/routers/reports/beer_sale_report.py index e5bba15..af86aff 100644 --- a/barker/barker/routers/reports/beer_sale_report.py +++ b/barker/barker/routers/reports/beer_sale_report.py @@ -1,16 +1,15 @@ from datetime import date, timedelta -from operator import or_ from fastapi import APIRouter, Depends, Security -from sqlalchemy import Date, and_ +from sqlalchemy import Date from sqlalchemy.sql.expression import func, select 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_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 @@ -18,6 +17,7 @@ from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas.beer_consumption_report import BeerConsumptionReport, BeerConsumptionReportItem from ...schemas.user_token import UserToken +from .. import _pv_onclause, _sv_onclause from . import check_audit_permission, report_finish_date, report_start_date @@ -39,22 +39,8 @@ def beer_consumption( Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") sum_ = func.sum(Inventory.quantity * SkuVersion.fraction).label("sum") - 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) query = ( select(day, ProductVersion.name, sum_, ProductVersion.fraction_units) .join(Voucher.kots) @@ -64,6 +50,7 @@ def beer_consumption( .join(StockKeepingUnit.product) .join(ProductVersion, onclause=product_version_onclause) .where( + Inventory.type_ != InventoryType.bundle, day >= start_date, day <= finish_date, ) diff --git a/barker/barker/routers/reports/discount_report.py b/barker/barker/routers/reports/discount_report.py index 1576ca8..3b7c386 100644 --- a/barker/barker/routers/reports/discount_report.py +++ b/barker/barker/routers/reports/discount_report.py @@ -3,24 +3,24 @@ import uuid from datetime import date, timedelta from fastapi import APIRouter, Cookie, Depends, Security -from sqlalchemy import Date, and_, func, or_, select +from sqlalchemy import Date, func, select 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.inventory import Inventory +from ...models.inventory_type import InventoryType from ...models.kot import Kot -from ...models.product import Product from ...models.product_version import ProductVersion from ...models.sale_category import SaleCategory -from ...models.sku_version import SkuVersion from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.discount_report import print_discount_report from ...schemas.discount_report import DiscountReport, DiscountReportItem from ...schemas.user_token import UserToken +from .. import _pv_onclause from . import check_audit_permission, report_finish_date, report_start_date @@ -47,33 +47,18 @@ def get_discount_report(start_date: date, finish_date: date, db: Session) -> lis Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") amount = func.sum(Inventory.quantity * Inventory.effective_price * Inventory.discount).label("Amount") - 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) list_ = db.execute( select(SaleCategory.name, amount) .join(Voucher.kots) .join(Kot.inventories) .join(Inventory.sku) - .join(SkuVersion, onclause=sku_version_onclause) .join(StockKeepingUnit.product) .join(ProductVersion, onclause=product_version_onclause) .join(ProductVersion.sale_category) .where( Inventory.discount != 0, + Inventory.type_ != InventoryType.bundle, day >= start_date, day <= finish_date, Voucher.voucher_type.in_([VoucherType.REGULAR_BILL, VoucherType.KOT]), diff --git a/barker/barker/routers/reports/menu_engineering_report.py b/barker/barker/routers/reports/menu_engineering_report.py index 743d3af..4188d6d 100644 --- a/barker/barker/routers/reports/menu_engineering_report.py +++ b/barker/barker/routers/reports/menu_engineering_report.py @@ -4,7 +4,7 @@ from datetime import date, timedelta from decimal import Decimal from fastapi import APIRouter, Depends, Security -from sqlalchemy import Date, and_, func, nulls_last, or_, select +from sqlalchemy import Date, func, nulls_last, select from sqlalchemy.orm import Session from ...core.config import settings @@ -22,6 +22,7 @@ from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas.menu_engineering_report import MeItem, MeReport from ...schemas.user_token import UserToken +from .. import _pv_active, _sv_active from . import check_audit_permission, report_finish_date, report_start_date @@ -48,20 +49,8 @@ def menu_engineering_report(start_date: date, finish_date: date, db: Session) -> day = func.cast( Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_valid = and_( - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_valid = and_( - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) + product_version_active = _pv_active(day) + sku_version_active = _sv_active(day) list_ = db.execute( select( SaleCategory.name, @@ -87,8 +76,8 @@ def menu_engineering_report(start_date: date, finish_date: date, db: Session) -> day >= start_date, day <= finish_date, Voucher.voucher_type == VoucherType.REGULAR_BILL, - product_version_valid, - sku_version_valid, + product_version_active, + sku_version_active, ) .group_by( SaleCategory.name, diff --git a/barker/barker/routers/reports/product_sale_report.py b/barker/barker/routers/reports/product_sale_report.py index 5c059b6..ece776c 100644 --- a/barker/barker/routers/reports/product_sale_report.py +++ b/barker/barker/routers/reports/product_sale_report.py @@ -4,7 +4,7 @@ from datetime import date, timedelta from typing import Annotated from fastapi import APIRouter, Cookie, Depends, Query, Security -from sqlalchemy import Date, and_, func, or_, select +from sqlalchemy import Date, func, select from sqlalchemy.orm import Session from ...core.config import settings @@ -14,7 +14,6 @@ from ...models.food_table import FoodTable from ...models.inventory import Inventory from ...models.kot import Kot from ...models.menu_category import MenuCategory -from ...models.product import Product from ...models.product_version import ProductVersion from ...models.sale_category import SaleCategory from ...models.sku_version import SkuVersion @@ -22,8 +21,10 @@ from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.product_sale_report import print_product_sale_report -from ...schemas.product_sale_report import ProductSaleReport, ProductSaleReportItem, UserLink +from ...schemas.product_sale_report import ProductSaleReport, ProductSaleReportItem +from ...schemas.user import UserLink from ...schemas.user_token import UserToken +from .. import _pv_onclause, _sv_onclause from . import check_audit_permission, report_finish_date, report_start_date @@ -54,22 +55,8 @@ def product_sale_report( day = func.cast( Voucher.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) query = ( select( ProductVersion.id, diff --git a/barker/barker/routers/reports/product_updates_report.py b/barker/barker/routers/reports/product_updates_report.py index 5b64942..669b12f 100644 --- a/barker/barker/routers/reports/product_updates_report.py +++ b/barker/barker/routers/reports/product_updates_report.py @@ -1,6 +1,6 @@ from datetime import date, timedelta -from fastapi import APIRouter, Depends, Security +from fastapi import APIRouter, Depends, HTTPException, Security, status from sqlalchemy import and_, nullsfirst, or_, select from sqlalchemy.orm import Session, contains_eager @@ -11,6 +11,7 @@ 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 ...schemas.product_updates_report import ProductUpdatesReport from ...schemas.user_token import UserToken from . import report_finish_date, report_start_date @@ -18,21 +19,24 @@ from . import report_finish_date, report_start_date router = APIRouter() -@router.get("") +@router.get("", response_model=ProductUpdatesReport) def product_updates_report_view( start_date: date = Depends(report_start_date), finish_date: date = Depends(report_finish_date), user: UserToken = Security(get_user, scopes=["product-sale-report"]), -): +) -> ProductUpdatesReport: try: with SessionFuture() as db: - return { - "startDate": start_date.strftime("%d-%b-%Y"), - "finishDate": finish_date.strftime("%d-%b-%Y"), - "report": product_updates_report(start_date, finish_date, db), - } + return ProductUpdatesReport( + start_date=start_date, + finish_date=finish_date, + report=product_updates_report(start_date, finish_date, db), + ) except Exception as e: - return {"error": str(e)} + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) def product_updates_report(start_date: date, finish_date: date, db: Session) -> list[str]: diff --git a/barker/barker/routers/reports/sale_report.py b/barker/barker/routers/reports/sale_report.py index 23ea0fe..0969a6b 100644 --- a/barker/barker/routers/reports/sale_report.py +++ b/barker/barker/routers/reports/sale_report.py @@ -5,7 +5,7 @@ from decimal import Decimal from typing import Annotated from fastapi import APIRouter, Cookie, Depends, Query, Security -from sqlalchemy import Date, and_, func, or_, select +from sqlalchemy import Date, func, select from sqlalchemy.orm import Session from ...core.config import settings @@ -13,18 +13,20 @@ from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture from ...models.food_table import FoodTable from ...models.inventory import Inventory +from ...models.inventory_type import InventoryType from ...models.kot import Kot -from ...models.product import Product from ...models.product_version import ProductVersion from ...models.sale_category import SaleCategory from ...models.settle_option import SettleOption from ...models.settlement import Settlement +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.sale_report import print_sale_report from ...schemas.sale_report import SaleReport, SaleReportItem from ...schemas.user import UserLink from ...schemas.user_token import UserToken +from .. import _pv_onclause from . import check_audit_permission, report_finish_date, report_start_date from .tax_report import get_tax @@ -60,26 +62,19 @@ def get_sale(start_date: date, finish_date: date, id_: uuid.UUID | None, db: Ses day = func.cast( Voucher.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, - ), - ) + product_version_onclause = _pv_onclause(day) query = ( select(SaleCategory.name, func.sum(Inventory.net)) + .select_from(Inventory) .join(Inventory.kot) .join(Kot.voucher) - .join(Inventory.product) + .join(Inventory.sku) + .join(StockKeepingUnit.product) .join(ProductVersion, onclause=product_version_onclause) .join(ProductVersion.sale_category) .join(Voucher.food_table) .where( + Inventory.type_ != InventoryType.bundle, day >= start_date, day <= finish_date, Voucher.voucher_type == VoucherType.REGULAR_BILL, diff --git a/barker/barker/routers/reports/tax_report.py b/barker/barker/routers/reports/tax_report.py index a1aed21..957a373 100644 --- a/barker/barker/routers/reports/tax_report.py +++ b/barker/barker/routers/reports/tax_report.py @@ -13,6 +13,7 @@ from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture from ...models.food_table import FoodTable from ...models.inventory import Inventory +from ...models.inventory_type import InventoryType from ...models.kot import Kot from ...models.tax import Tax from ...models.voucher import Voucher @@ -60,6 +61,7 @@ def get_tax(s: date, f: date, id_: uuid.UUID | None, db: Session) -> list[TaxRep .join(Inventory.tax) .join(Voucher.food_table) .where( + Inventory.type_ != InventoryType.bundle_item, Voucher.date >= start_date, Voucher.date <= finish_date, Voucher.voucher_type == VoucherType.REGULAR_BILL, diff --git a/barker/barker/routers/role.py b/barker/barker/routers/role.py index 4db61c5..4a7203e 100644 --- a/barker/barker/routers/role.py +++ b/barker/barker/routers/role.py @@ -119,7 +119,7 @@ def role_info(item: Role, db: Session) -> schemas.Role: id_=item.id, name=item.name, permissions=[ - schemas.PermissionItem( + PermissionItem( id_=p.id, name=p.name, enabled=p in item.permissions, @@ -133,7 +133,7 @@ def role_blank(db: Session) -> schemas.RoleBlank: return schemas.RoleBlank( name="", permissions=[ - schemas.PermissionItem(id_=p.id, name=p.name, enabled=False) + PermissionItem(id_=p.id, name=p.name, enabled=False) for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all() ], ) diff --git a/barker/barker/routers/sale_category.py b/barker/barker/routers/sale_category.py index a466ddf..09fcae0 100644 --- a/barker/barker/routers/sale_category.py +++ b/barker/barker/routers/sale_category.py @@ -13,6 +13,7 @@ from ..db.session import SessionFuture from ..models.product_version import ProductVersion from ..models.sale_category import SaleCategory from ..schemas import sale_category as schemas +from ..schemas.tax import TaxLink from ..schemas.user_token import UserToken @@ -118,7 +119,7 @@ def sale_category_info(item: SaleCategory) -> schemas.SaleCategory: id_=item.id, name=item.name, discount_limit=item.discount_limit, - tax=schemas.TaxLink(id_=item.tax_id, name=item.tax.name, rate=item.tax.rate), + tax=TaxLink(id_=item.tax_id, name=item.tax.name, rate=item.tax.rate), ) diff --git a/barker/barker/routers/section_printer.py b/barker/barker/routers/section_printer.py index bc355de..2c66aad 100644 --- a/barker/barker/routers/section_printer.py +++ b/barker/barker/routers/section_printer.py @@ -13,6 +13,8 @@ from ..db.session import SessionFuture from ..models.sale_category import SaleCategory from ..models.section_printer import SectionPrinter from ..schemas import section_printer as schemas +from ..schemas.printer import PrinterLink +from ..schemas.sale_category import SaleCategoryLink from ..schemas.user_token import UserToken @@ -126,8 +128,8 @@ def report(section_id: uuid.UUID | None, db: Session) -> list[schemas.SectionPri ) list_.append( schemas.SectionPrinter( - sale_category=None if sc_id is None else schemas.SaleCategoryLink(id_=sc_id, name=sc_name), - printer=None if section_printer is None else schemas.PrinterLink(id_=section_printer.printer_id), + sale_category=None if sc_id is None else SaleCategoryLink(id_=sc_id, name=sc_name), + printer=None if section_printer is None else PrinterLink(id_=section_printer.printer_id), copies=0 if section_printer is None else section_printer.copies, ) ) diff --git a/barker/barker/routers/setting.py b/barker/barker/routers/setting.py index affff95..cd0abcb 100644 --- a/barker/barker/routers/setting.py +++ b/barker/barker/routers/setting.py @@ -6,17 +6,18 @@ from sqlalchemy import select from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.db_setting import DbSetting +from ..schemas.db_setting import DbSetting as DbSettingSchema from ..schemas.user_token import UserToken router = APIRouter() -@router.get("/{id_}") +@router.get("/{id_}", response_model=DbSettingSchema) def get( id_: uuid.UUID, user: UserToken = Security(get_user), -): +) -> DbSettingSchema: with SessionFuture() as db: data = db.execute(select(DbSetting).where(DbSetting.name == id_)).scalar_one() - return {"id": data.id, "name": data.name, "details": data.data} + return DbSettingSchema(id_=data.id, name=data.name, details=data.data) diff --git a/barker/barker/routers/table.py b/barker/barker/routers/table.py index f56caef..f7367ba 100644 --- a/barker/barker/routers/table.py +++ b/barker/barker/routers/table.py @@ -12,6 +12,8 @@ from ..db.session import SessionFuture from ..models.food_table import FoodTable from ..models.overview import Overview from ..schemas import table as schemas +from ..schemas.running_table import RunningTable +from ..schemas.section import SectionLink from ..schemas.user_token import UserToken @@ -33,7 +35,7 @@ def sort_order( id_=item.id, name=item.name, seats=item.seats, - section=schemas.SectionLink(id_=item.section_id, name=item.section.name), + section=SectionLink(id_=item.section_id, name=item.section.name), is_active=item.is_active, sort_order=item.sort_order, ) @@ -125,8 +127,8 @@ def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Table]: ] -@router.get("/running") -def show_running(user: UserToken = Depends(get_user)): +@router.get("/running", response_model=list[RunningTable]) +def show_running(user: UserToken = Depends(get_user)) -> list[RunningTable]: with SessionFuture() as db: list_ = ( db.execute( @@ -136,27 +138,25 @@ def show_running(user: UserToken = Depends(get_user)): .all() ) - food_tables = [] + food_tables: list[RunningTable] = [] for item in list_: - ft = { - "id": item.id, - "name": item.name, - "seats": item.seats, - "section": {"id": item.section_id, "name": item.section.name}, - "isActive": item.is_active, - "sortOrder": item.sort_order, - } + ft = RunningTable( + id_=item.id, + name=item.name, + seats=item.seats, + section=SectionLink(id_=item.section_id, name=item.section.name), + is_active=item.is_active, + sort_order=item.sort_order, + ) if item.status is not None: - ft["status"] = item.status.status - ft["voucherId"] = item.status.voucher_id - ft["pax"] = item.status.voucher.pax - ft["date"] = (item.status.voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime( - "%d-%b-%Y %H:%M" - ) - ft["amount"] = round(item.status.voucher.amount, 0) + ft.status = item.status.status + ft.voucher_id = item.status.voucher_id + ft.pax = item.status.voucher.pax + ft.date_ = item.status.voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES) + ft.amount = int(round(item.status.voucher.amount, 0)) if item.status.guest is not None: - ft["guest"] = item.status.guest.customer.name - ft["bold"] = item.status.guest.customer.print_in_bill + ft.guest = item.status.guest.customer.name + ft.bold = item.status.guest.customer.print_in_bill food_tables.append(ft) return food_tables @@ -188,7 +188,7 @@ def food_table_info(item: FoodTable) -> schemas.Table: id_=item.id, name=item.name, seats=item.seats, - section=schemas.SectionLink(id_=item.section_id, name=item.section.name), + section=SectionLink(id_=item.section_id, name=item.section.name), is_active=item.is_active, sort_order=item.sort_order, ) diff --git a/barker/barker/routers/tax.py b/barker/barker/routers/tax.py index bbcfcc2..d948117 100644 --- a/barker/barker/routers/tax.py +++ b/barker/barker/routers/tax.py @@ -14,6 +14,7 @@ from ..models.inventory import Inventory from ..models.sale_category import SaleCategory from ..models.tax import Tax from ..schemas import tax as schemas +from ..schemas.regime import RegimeLink from ..schemas.user_token import UserToken @@ -129,7 +130,7 @@ def tax_info(item: Tax) -> schemas.Tax: id_=item.id, name=item.name, rate=item.rate, - regime=schemas.RegimeLink(id_=item.regime_id, name=item.regime.name), + regime=RegimeLink(id_=item.regime_id, name=item.regime.name), is_fixture=item.is_fixture, ) diff --git a/barker/barker/routers/temporal_product.py b/barker/barker/routers/temporal_product.py index 78f63f8..1db943a 100644 --- a/barker/barker/routers/temporal_product.py +++ b/barker/barker/routers/temporal_product.py @@ -102,14 +102,14 @@ def check_gaps(data: TemporalProduct, db: Session) -> None: detail="Product and SKU versions validity do not match", ) for i, p_item in enumerate(data.products[1:], start=1): - if data.products[i - 1].valid_till + timedelta(days=1) != p_item.valid_from: + if data.products[i - 1].valid_till + timedelta(days=1) != p_item.valid_from: # type: ignore[operator] raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Gaps in product versions exist", ) for sku_list in skus.values(): for i, s_item in enumerate(sku_list[1:], start=1): - if sku_list[i - 1].valid_till + timedelta(days=1) != s_item.valid_from: + if sku_list[i - 1].valid_till + timedelta(days=1) != s_item.valid_from: # type: ignore[operator] raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Gaps in sku versions exist", diff --git a/barker/barker/routers/user.py b/barker/barker/routers/user.py index c74a5bb..819bc2c 100644 --- a/barker/barker/routers/user.py +++ b/barker/barker/routers/user.py @@ -166,7 +166,7 @@ def user_info(item: User, db: Session, user: UserToken) -> schemas.User: password="", locked_out=item.locked_out, roles=[ - schemas.RoleItem( + RoleItem( id_=r.id, name=r.name, enabled=r in item.roles, @@ -194,7 +194,7 @@ def blank_user_info(db: Session) -> schemas.UserIn: password="", locked_out=False, roles=[ - schemas.RoleItem(id_=r.id, name=r.name, enabled=False) + RoleItem(id_=r.id, name=r.name, enabled=False) for r in db.execute(select(Role).order_by(Role.name)).scalars().all() ], ) diff --git a/barker/barker/routers/voucher/__init__.py b/barker/barker/routers/voucher/__init__.py index b41c5db..c8dc23e 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 ColumnElement, 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,74 @@ 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: ColumnElement[bool], + sku_version_onclause: ColumnElement[bool], +) -> 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: ColumnElement[bool], + sku_version_onclause: ColumnElement[bool], +) -> 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/change.py b/barker/barker/routers/voucher/change.py index 73febea..da2c766 100644 --- a/barker/barker/routers/voucher/change.py +++ b/barker/barker/routers/voucher/change.py @@ -49,7 +49,7 @@ def change( ) bill_changed: bool = ( len( - set((i.id, i.amount) for k in old.kots for i in k.inventories) + set((i.id, i.amount) for k in old.kots for i in k.inventories if not i.parent_id) ^ set((i.id_, i.amount) for k in data.kots for i in k.inventories) ) != 0 diff --git a/barker/barker/routers/voucher/save.py b/barker/barker/routers/voucher/save.py index 79472ed..755bf06 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,129 @@ 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 * data_inv.quantity) != 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 +228,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 93976e0..c2c486e 100644 --- a/barker/barker/routers/voucher/show.py +++ b/barker/barker/routers/voucher/show.py @@ -4,7 +4,7 @@ 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 ...core.config import settings @@ -14,6 +14,7 @@ from ...models.bill import Bill from ...models.food_table import FoodTable from ...models.guest_book import GuestBook from ...models.inventory import Inventory +from ...models.inventory_type import InventoryType from ...models.kot import Kot from ...models.overview import Overview from ...models.product import Product @@ -23,25 +24,27 @@ 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.customer import CustomerLink +from ...schemas.modifier import ModifierLink +from ...schemas.table import TableLink +from ...schemas.tax import TaxLink +from ...schemas.user import UserLink from ...schemas.user_token import UserToken -from ...schemas.voucher_out import ( - CustomerLink, - InventoryProduct, - ModifierLink, - ProductMenuCategory, - ProductSaleCategory, - TableLink, - TaxLink, - UserLink, - VoucherBlank, - VoucherOut, -) from ...schemas.voucher_out import ( Inventory as InventoryOut, ) +from ...schemas.voucher_out import ( + InventoryProduct, + ProductMenuCategory, + ProductSaleCategory, + VoucherBlank, + VoucherOut, +) from ...schemas.voucher_out import ( Kot as KotOut, ) +from .. import _pv_onclause, _sv_onclause +from . import get_voucher router = APIRouter() @@ -49,59 +52,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 +72,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 +131,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 +153,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 +178,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 +195,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 9dc0f4e..03ce366 100644 --- a/barker/barker/routers/voucher/split.py +++ b/barker/barker/routers/voucher/split.py @@ -1,28 +1,32 @@ 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 ...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 from ...models.voucher import Voucher from ...models.voucher_type import VoucherType +from ...routers import _pv_onclause, _sv_onclause from ...routers.voucher import ( check_permissions, 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 +47,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 +62,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 +147,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 +160,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 67bd2d7..bc6aedb 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 ColumnElement, Date, 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: ColumnElement[bool]) -> 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, @@ -52,68 +75,32 @@ def update_route( try: with SessionFuture() as db: now = datetime.now(UTC).replace(tzinfo=None) + day = func.cast( + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") 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 = get_voucher(id_, db, _pv_onclause(day), _sv_onclause(day)) 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 +117,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 +177,86 @@ 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 * data_inv.quantity) != 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 e37af9f..42c670a 100644 --- a/barker/barker/schemas/beer_consumption_report.py +++ b/barker/barker/schemas/beer_consumption_report.py @@ -1,8 +1,16 @@ from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SerializationInfo, + field_serializer, + field_validator, + model_serializer, +) from . import Daf, to_camel @@ -18,11 +26,11 @@ class BeerConsumptionReportItem(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("date_") - def serialize_date(self, value: date, _info: Any) -> str: + def serialize_date(self, value: date, _info: SerializationInfo) -> str: 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) @@ -64,7 +72,7 @@ class BeerConsumptionReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -75,5 +83,5 @@ class BeerConsumptionReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/bill_settlement_report.py b/barker/barker/schemas/bill_settlement_report.py index ea56493..4536e77 100644 --- a/barker/barker/schemas/bill_settlement_report.py +++ b/barker/barker/schemas/bill_settlement_report.py @@ -1,7 +1,6 @@ from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel @@ -21,7 +20,7 @@ class BillSettlementItem(BaseModel): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("date_") - def serialize_date(self, value: datetime, _info: Any) -> str: + def serialize_date(self, value: datetime, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y %H:%M") @@ -39,7 +38,7 @@ class BillSettlement(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -50,5 +49,5 @@ class BillSettlement(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py new file mode 100644 index 0000000..2c7c9f6 --- /dev/null +++ b/barker/barker/schemas/bundle.py @@ -0,0 +1,60 @@ +import uuid + +from decimal import Decimal +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field + +from . import Daf, to_camel +from .menu_category import MenuCategoryLink +from .sale_category import SaleCategoryLink + + +class BundleItem(BaseModel): + id_: uuid.UUID | None = None + 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))] + print_in_bill: bool + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class BundleIn(BaseModel): + name: Annotated[str, Field(min_length=1)] + units: Annotated[str, Field(min_length=1)] + sale_price: Annotated[Daf, Field(ge=Decimal(0))] + sale_category: SaleCategoryLink + has_happy_hour: bool + is_not_available: bool + sort_order: int + + menu_category: MenuCategoryLink + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class Bundle(BundleIn): + id_: uuid.UUID # Sku ID + version_id: uuid.UUID # SkuVersion ID + + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class BundleBlank(BaseModel): + name: str + units: str + sale_price: Daf + sale_category: SaleCategoryLink | None + has_happy_hour: bool + is_not_available: bool + sort_order: int + + menu_category: MenuCategoryLink | None = None + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/cashier_report.py b/barker/barker/schemas/cashier_report.py index 1f2b7e0..97e1345 100644 --- a/barker/barker/schemas/cashier_report.py +++ b/barker/barker/schemas/cashier_report.py @@ -1,7 +1,6 @@ from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel from .user import UserLink @@ -60,7 +59,7 @@ class CashierReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -71,5 +70,5 @@ class CashierReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/customer.py b/barker/barker/schemas/customer.py index 8953942..35c0247 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/db_setting.py b/barker/barker/schemas/db_setting.py new file mode 100644 index 0000000..13023bf --- /dev/null +++ b/barker/barker/schemas/db_setting.py @@ -0,0 +1,19 @@ +import uuid + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from . import to_camel + + +class DbSetting(BaseModel): + id_: uuid.UUID + name: str + details: dict[str, Any] + + model_config = ConfigDict( + str_strip_whitespace=True, + alias_generator=to_camel, + populate_by_name=True, + ) diff --git a/barker/barker/schemas/device.py b/barker/barker/schemas/device.py index 1a299ab..ada3acc 100644 --- a/barker/barker/schemas/device.py +++ b/barker/barker/schemas/device.py @@ -1,16 +1,16 @@ import uuid from datetime import datetime -from typing import Any +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_serializer +from pydantic import BaseModel, ConfigDict, Field, SerializationInfo, field_serializer from . import to_camel 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) @@ -24,15 +24,15 @@ class Device(DeviceIn): model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @field_serializer("creation_date") - def serialize_creation_date(self, value: datetime, _info: Any) -> str: + def serialize_creation_date(self, value: datetime, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y %H:%M") @field_serializer("last_date") - def serialize_last_date(self, value: datetime | None, _info: Any) -> str | None: + def serialize_last_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y %H:%M") 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/discount_report.py b/barker/barker/schemas/discount_report.py index 9912265..d74eecf 100644 --- a/barker/barker/schemas/discount_report.py +++ b/barker/barker/schemas/discount_report.py @@ -1,7 +1,6 @@ from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel @@ -26,7 +25,7 @@ class DiscountReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -37,5 +36,5 @@ class DiscountReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/guest_book.py b/barker/barker/schemas/guest_book.py index 7827364..2e7eb0f 100644 --- a/barker/barker/schemas/guest_book.py +++ b/barker/barker/schemas/guest_book.py @@ -1,12 +1,13 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Annotated from pydantic import ( BaseModel, ConfigDict, Field, + SerializationInfo, field_serializer, field_validator, model_validator, @@ -20,7 +21,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 @@ -38,7 +39,7 @@ class GuestBookIn(BaseModel): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("booking_date") - def serialize_booking_date(self, value: datetime | None, _info: Any) -> str | None: + def serialize_booking_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y %H:%M") @field_validator("arrival_date", mode="before") @@ -51,7 +52,7 @@ class GuestBookIn(BaseModel): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("arrival_date") - def serialize_arrival_date(self, value: datetime | None, _info: Any) -> str | None: + def serialize_arrival_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y %H:%M") @model_validator(mode="after") @@ -75,7 +76,7 @@ class GuestBook(GuestBookIn): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("last_edit_date") - def serialize_last_edit_date(self, value: datetime, _info: Any) -> str: + def serialize_last_edit_date(self, value: datetime, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y %H:%M") @@ -107,7 +108,7 @@ class GuestBookListItem(BaseModel): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("booking_date") - def serialize_booking_date(self, value: datetime | None, _info: Any) -> str | None: + def serialize_booking_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y %H:%M") @field_validator("arrival_date", mode="before") @@ -120,7 +121,7 @@ class GuestBookListItem(BaseModel): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("arrival_date") - def serialize_arrival_date(self, value: datetime | None, _info: Any) -> str | None: + def serialize_arrival_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y %H:%M") @field_validator("last_edit_date", mode="before") @@ -131,7 +132,7 @@ class GuestBookListItem(BaseModel): return datetime.strptime(value, "%d-%b-%Y %H:%M") @field_serializer("last_edit_date") - def serialize_last_edit_date(self, value: datetime, _info: Any) -> str: + def serialize_last_edit_date(self, value: datetime, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y %H:%M") @@ -148,5 +149,5 @@ class GuestBookList(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("date_") - def serialize_date(self, value: date, _info: Any) -> str: + def serialize_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/health_response.py b/barker/barker/schemas/health_response.py new file mode 100644 index 0000000..a9c32f3 --- /dev/null +++ b/barker/barker/schemas/health_response.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str + database: str diff --git a/barker/barker/schemas/master.py b/barker/barker/schemas/master.py index 766d9b7..f40541d 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 041a7f5..1900ce1 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/menu_engineering_report.py b/barker/barker/schemas/menu_engineering_report.py index 57b11c4..16de451 100644 --- a/barker/barker/schemas/menu_engineering_report.py +++ b/barker/barker/schemas/menu_engineering_report.py @@ -1,9 +1,8 @@ import uuid from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel @@ -37,7 +36,7 @@ class MeReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -48,5 +47,5 @@ class MeReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/modifier.py b/barker/barker/schemas/modifier.py index 582e12a..32536a4 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 76739df..089c703 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 02d3b47..27be893 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 cf8c445..b92cf4e 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 d0cf3ef..60dffec 100644 --- a/barker/barker/schemas/product.py +++ b/barker/barker/schemas/product.py @@ -1,9 +1,9 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, Field, SerializationInfo, field_serializer, field_validator from . import to_camel from .sale_category import SaleCategoryLink @@ -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] @@ -37,7 +37,7 @@ class Product(ProductIn): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_from") - def serialize_valid_from(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_from(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") @field_validator("valid_till", mode="before") @@ -50,7 +50,7 @@ class Product(ProductIn): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_till") - def serialize_valid_till(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_till(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/product_link.py b/barker/barker/schemas/product_link.py index bcbdcd6..a65bcdf 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 09f3aaf..589edc1 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 03034f0..79884f1 100644 --- a/barker/barker/schemas/product_sale_report.py +++ b/barker/barker/schemas/product_sale_report.py @@ -2,9 +2,17 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SerializationInfo, + field_serializer, + field_validator, + model_serializer, +) from . import Daf, to_camel from .user import UserLink @@ -16,7 +24,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) @@ -57,7 +65,7 @@ class ProductSaleReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -68,5 +76,5 @@ class ProductSaleReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/product_updates_report.py b/barker/barker/schemas/product_updates_report.py new file mode 100644 index 0000000..eb04bef --- /dev/null +++ b/barker/barker/schemas/product_updates_report.py @@ -0,0 +1,35 @@ +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator + +from . import to_camel + + +class ProductUpdatesReport(BaseModel): + start_date: date + finish_date: date + report: list[str] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + @field_validator("start_date", mode="before") + @classmethod + def parse_start_date(cls, value: str | date) -> date: + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() + + @field_serializer("start_date") + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: + return value.strftime("%d-%b-%Y") + + @field_validator("finish_date", mode="before") + @classmethod + def parse_finish_date(cls, value: str | date) -> date: + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() + + @field_serializer("finish_date") + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: + return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/regime.py b/barker/barker/schemas/regime.py index c7dfbc9..3ae43fe 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 f488632..caa8ad6 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/running_table.py b/barker/barker/schemas/running_table.py new file mode 100644 index 0000000..63de265 --- /dev/null +++ b/barker/barker/schemas/running_table.py @@ -0,0 +1,43 @@ +import uuid + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator + +from . import to_camel +from .section import SectionLink + + +class RunningTable(BaseModel): + id_: uuid.UUID + name: str + seats: int + section: SectionLink + is_active: bool + sort_order: int + + # present only when status exists in DB + status: str | None = None + voucher_id: uuid.UUID | None = None + pax: int | None = None + date_: datetime | None = None + amount: float | None = None # you round to 0 decimals; keep Decimal if you prefer + guest: str | None = None + bold: bool | None = None + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + @field_validator("date_", mode="before") + @classmethod + def parse_date(cls, value: str | datetime | None) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value + return datetime.strptime(value, "%d-%b-%Y %H:%M") + + @field_serializer("date_") + def serialize_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: + if value is None: + return None + return value.strftime("%d-%b-%Y %H:%M") diff --git a/barker/barker/schemas/sale_category.py b/barker/barker/schemas/sale_category.py index cdafafb..a055eaf 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/sale_report.py b/barker/barker/schemas/sale_report.py index ba5ffe4..d7ac6a0 100644 --- a/barker/barker/schemas/sale_report.py +++ b/barker/barker/schemas/sale_report.py @@ -1,9 +1,8 @@ import uuid from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel from .user import UserLink @@ -31,7 +30,7 @@ class SaleReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -42,5 +41,5 @@ class SaleReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/section.py b/barker/barker/schemas/section.py index 1a72a5c..1c19712 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 e0a90a1..772c073 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 cd2483b..5a1295a 100644 --- a/barker/barker/schemas/stock_keeping_unit.py +++ b/barker/barker/schemas/stock_keeping_unit.py @@ -2,9 +2,9 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, Field, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel from .menu_category import MenuCategoryLink @@ -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 @@ -38,7 +38,7 @@ class StockKeepingUnit(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_from") - def serialize_valid_from(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_from(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") @field_validator("valid_till", mode="before") @@ -51,5 +51,5 @@ class StockKeepingUnit(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_till") - def serialize_valid_till(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_till(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/table.py b/barker/barker/schemas/table.py index e2dcdcb..0f04f87 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 7cea67a..526f554 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/tax_report.py b/barker/barker/schemas/tax_report.py index fe257b8..1f0b9ca 100644 --- a/barker/barker/schemas/tax_report.py +++ b/barker/barker/schemas/tax_report.py @@ -1,7 +1,6 @@ from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel @@ -28,7 +27,7 @@ class TaxReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("start_date") - def serialize_start_date(self, value: date, _info: Any) -> str: + def serialize_start_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") @field_validator("finish_date", mode="before") @@ -39,5 +38,5 @@ class TaxReport(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("finish_date") - def serialize_finish_date(self, value: date, _info: Any) -> str: + def serialize_finish_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/temporal_product.py b/barker/barker/schemas/temporal_product.py index a8ad9f4..91eefe1 100644 --- a/barker/barker/schemas/temporal_product.py +++ b/barker/barker/schemas/temporal_product.py @@ -2,9 +2,9 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, Field, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel from .menu_category import MenuCategoryLink @@ -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 @@ -33,7 +33,7 @@ class Product(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_from") - def serialize_valid_from(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_from(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") @field_validator("valid_till", mode="before") @@ -46,20 +46,20 @@ class Product(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_till") - def serialize_valid_till(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_till(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") 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 @@ -78,7 +78,7 @@ class StockKeepingUnit(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_from") - def serialize_valid_from(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_from(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") @field_validator("valid_till", mode="before") @@ -91,11 +91,11 @@ class StockKeepingUnit(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("valid_till") - def serialize_valid_till(self, value: date | None, _info: Any) -> str | None: + def serialize_valid_till(self, value: date | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y") class TemporalProduct(BaseModel): - products: list[Product] = Field(...) - skus: list[StockKeepingUnit] = Field(...) + 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/update_product_prices.py b/barker/barker/schemas/update_product_prices.py index e0bb17a..f45c901 100644 --- a/barker/barker/schemas/update_product_prices.py +++ b/barker/barker/schemas/update_product_prices.py @@ -1,9 +1,8 @@ import uuid from datetime import date, datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer, field_validator from . import Daf, to_camel @@ -30,5 +29,5 @@ class UpdateProductPrices(BaseModel): return datetime.strptime(value, "%d-%b-%Y").date() @field_serializer("date_") - def serialize_date(self, value: date, _info: Any) -> str: + def serialize_date(self, value: date, _info: SerializationInfo) -> str: return value.strftime("%d-%b-%Y") diff --git a/barker/barker/schemas/user.py b/barker/barker/schemas/user.py index c853d74..8ba3684 100644 --- a/barker/barker/schemas/user.py +++ b/barker/barker/schemas/user.py @@ -1,9 +1,8 @@ import uuid from datetime import datetime -from typing import Any -from pydantic import BaseModel, ConfigDict, field_serializer +from pydantic import BaseModel, ConfigDict, SerializationInfo, field_serializer from . import to_camel from .role import RoleItem @@ -44,5 +43,5 @@ class UserList(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @field_serializer("last_date") - def serialize_last_date(self, value: datetime | None, _info: Any) -> str | None: + def serialize_last_date(self, value: datetime | None, _info: SerializationInfo) -> str | None: return None if value is None else value.strftime("%d-%b-%Y %H:%M") diff --git a/barker/barker/schemas/voucher.py b/barker/barker/schemas/voucher.py index 9c31795..ae40eae 100644 --- a/barker/barker/schemas/voucher.py +++ b/barker/barker/schemas/voucher.py @@ -1,9 +1,11 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from ..models.inventory_type import InventoryType from . import Daf, to_camel from .customer import CustomerLink from .modifier import ModifierLink @@ -16,12 +18,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 +60,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 +68,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 95d74b9..3ed3d12 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 pydantic import BaseModel, ConfigDict, Field, SerializationInfo, 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 @@ -101,7 +110,7 @@ class Kot(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @field_serializer("date_") - def serialize_date(self, value: datetime, _info): + def serialize_date(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%d-%b-%Y %H:%M:%S") @@ -140,25 +149,25 @@ class VoucherOut(BaseModel): ) @field_serializer("date_") - def serialize_date(self, value: datetime, _info): + def serialize_date(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%H:%M") @field_serializer("date_tip") - def serialize_date_tip(self, value: datetime, _info): + def serialize_date_tip(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%d-%b-%Y %H:%M:%S") @field_serializer("creation_date") - def serialize_creation_date(self, value: datetime, _info): + def serialize_creation_date(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%H:%M") @field_serializer("creation_date_tip") - def serialize_creation_date_tip(self, value: datetime, _info): + def serialize_creation_date_tip(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%d-%b-%Y %H:%M:%S") @field_serializer("last_edit_date") - def serialize_last_edit_date(self, value: datetime, _info): + def serialize_last_edit_date(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%H:%M") @field_serializer("last_edit_date_tip") - def serialize_last_edit_date_tip(self, value: datetime, _info): + def serialize_last_edit_date_tip(self, value: datetime, _info: SerializationInfo) -> str: return (value + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES)).strftime("%d-%b-%Y %H:%M:%S") diff --git a/barker/barker/tasks/printing.py b/barker/barker/tasks/printing.py deleted file mode 100644 index 4d484f4..0000000 --- a/barker/barker/tasks/printing.py +++ /dev/null @@ -1,33 +0,0 @@ -import re # noqa: F401 -import socket # noqa: F401 -import sys # noqa: F401 - -from arq import Retry - - -async def sent_to_printer(ctx: dict, data: str, address: str, cut_code: str): - print(ctx, data, address, cut_code) - try: - with open(address, "w") as printer: - printer.write(data) - printer.write("\n") - print(cut_code) - printer.write(cut_code) - except LookupError as e: - print("Lookup error:", e) - except: # noqa: E722 - print("Unexpected error:", sys.exc_info()[0]) - # retry the job with increasing back-off - # delays will be 5s, 10s, 15s, 20s - # after max_tries (default 5) the job will permanently fail - raise Retry(defer=ctx["job_try"] * 5) from None - - # GS = "\x1d" - # PAPER_CUT = GS + "V" - # # For the printer ESC/POS Reference - # # https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=87 - # # The m codes are in decimal and can be converted using the following table - # # https://www.eso.org/~ndelmott/ascii.html - # # The \x03 in all the following is to feed 3 lines before cut it can be increased or reduced - # FUNCTION_B_FULL_CUT = "\x41\x03" - # FUNCTION_B_PARTIAL_CUT = "\x42\x03" diff --git a/barker/barker/worker.py b/barker/barker/worker.py deleted file mode 100644 index dfec901..0000000 --- a/barker/barker/worker.py +++ /dev/null @@ -1,35 +0,0 @@ -import sys - -from aiohttp import ClientSession - -from barker.core.arq import settings -from barker.core.config import settings as sett -from barker.tasks.printing import sent_to_printer - - -sys.path.extend(["./"]) - - -async def startup(ctx): - ctx["session"] = ClientSession() - print(f"Worker listening for: {sett.WORKER_QUEUE_NAME}") - # print(f"Worker printing on: {sett.WORKER_PRINTER_ADDRESS}") - - -async def shutdown(ctx): - await ctx["session"].close() - - -class WorkerSettings: - """ - Settings for the ARQ worker. - """ - - queue_name = f"barker:print:{sett.WORKER_QUEUE_NAME}" - redis_settings = settings - functions: list = [sent_to_printer] - on_startup = startup - on_shutdown = shutdown - - -# command to run arq barker.worker.WorkerSettings diff --git a/barker/pyproject.toml b/barker/pyproject.toml index d46f78a..53f5e36 100644 --- a/barker/pyproject.toml +++ b/barker/pyproject.toml @@ -5,7 +5,7 @@ description = "Point of Sale for a restaurant" authors = ["tanshu "] [tool.poetry.dependencies] -python = "^3.13" +python = "^3.14" uvicorn = {extras = ["standard"], version = "^0.35.0"} fastapi = {extras = ["all"], version = "^0.115.14"} passlib = {extras = ["bcrypt"], version = "^1.7.4"} @@ -124,5 +124,10 @@ disallow_subclassing_any = true disallow_untyped_defs = true disallow_untyped_decorators = true -plugins = ["sqlalchemy.ext.mypy.plugin"] +plugins = ["sqlalchemy.ext.mypy.plugin", "pydantic.mypy"] # --strict end + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true diff --git a/barker/worker-start.sh b/barker/worker-start.sh deleted file mode 100755 index 7d828cf..0000000 --- a/barker/worker-start.sh +++ /dev/null @@ -1,5 +0,0 @@ -#! /usr/bin/env bash -set -e - -echo arq barker.worker.WorkerSettings -arq barker.worker.WorkerSettings diff --git a/bookie/src/app/app.routes.ts b/bookie/src/app/app.routes.ts index 1492a44..1e08706 100644 --- a/bookie/src/app/app.routes.ts +++ b/bookie/src/app/app.routes.ts @@ -13,6 +13,10 @@ export const routes: Routes = [ path: 'bill-settlement-report', loadChildren: () => import('./bill-settlement-report/bill-settlement-report.routes').then((mod) => mod.routes), }, + { + path: 'bundles', + loadChildren: () => import('./bundles/bundles.routes').then((mod) => mod.routes), + }, { path: 'cashier-report', loadChildren: () => import('./cashier-report/cashier-report.routes').then((mod) => mod.routes), diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts new file mode 100644 index 0000000..a1f78bd --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +import { BundleItem } from '../bundle'; + +export class BundleDetailDatasource extends DataSource { + constructor(private data: Observable) { + super(); + } + + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/barker/barker/tasks/__init__.py b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css similarity index 100% rename from barker/barker/tasks/__init__.py rename to bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html new file mode 100644 index 0000000..f056aa1 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html @@ -0,0 +1,33 @@ +

Edit Bundle Item

+ +
+
+ + Item + + + @for (p of products$ | async; track p) { + {{ p.name }} + } + + + +
+ + Quantity + + + + + Sale Price + + + Print in Bill? +
+
+
+ +
+ + +
diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts new file mode 100644 index 0000000..f282843 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; + +describe('BundleDetailDialogComponent', () => { + let component: BundleDetailDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BundleDetailDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BundleDetailDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts new file mode 100644 index 0000000..3c078c3 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts @@ -0,0 +1,116 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { debounceTime, distinctUntilChanged, Observable, of as observableOf, switchMap } from 'rxjs'; + +import { ProductQuery } from '../../core/product-query'; +import { ProductService } from '../../product/product.service'; +import { BundleItem } from '../bundle'; + +@Component({ + selector: 'app-bundle-detail-dialog', + templateUrl: './bundle-detail-dialog.component.html', + styleUrls: ['./bundle-detail-dialog.component.css'], + standalone: true, + imports: [ + CommonModule, + MatAutocompleteModule, + MatButtonModule, + MatCheckboxModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + ], +}) +export class BundleDetailDialogComponent implements OnInit { + private readonly dialogRef = inject>(MatDialogRef); + private readonly productSer = inject(ProductService); + + data = inject<{ item: BundleItem }>(MAT_DIALOG_DATA); + + selectedProduct: ProductQuery | null = null; + products$: Observable; + + form: FormGroup<{ + product: FormControl; + quantity: FormControl; + salePrice: FormControl; + printInBill: FormControl; + }>; + + constructor() { + this.form = new FormGroup({ + product: new FormControl('', { nonNullable: true }), + quantity: new FormControl(1, { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + printInBill: new FormControl(true, { nonNullable: true }), + }); + + this.products$ = this.form.controls.product.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => { + if (typeof x !== 'string') return observableOf([]); + return x.trim() === '' ? observableOf([]) : this.productSer.query(x); + }), + ); + + // typing after selecting invalidates prior selection + this.form.controls.product.valueChanges.subscribe((x) => { + if (typeof x === 'string') this.selectedProduct = null; + }); + } + + ngOnInit(): void { + // preload display text using existing name + this.form.setValue({ + product: this.data.item.name ?? '', + quantity: Number(this.data.item.quantity ?? 1), + salePrice: Number(this.data.item.salePrice ?? 0), + printInBill: this.data.item.printInBill, + }); + + this.selectedProduct = null; + } + + displayFn(product?: ProductQuery | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + productSelected(event: MatAutocompleteSelectedEvent): void { + const p = event.option.value as ProductQuery; + this.selectedProduct = p; + } + + accept(): void { + const v = this.form.value; + + const quantity = Number(v.quantity ?? 0); + if (Number.isNaN(quantity) || quantity <= 0) return; + + const salePrice = Number(v.salePrice ?? 0); + if (Number.isNaN(salePrice) || salePrice < 0) return; + + // If a new product was selected, update itemId + name + if (this.selectedProduct?.id) { + this.data.item.itemId = this.selectedProduct.id; + this.data.item.name = this.selectedProduct.name ?? ''; + } else { + // If no selection, don't allow changing SKU silently + // but still allow quantity/price edit + } + + this.data.item.quantity = quantity; + this.data.item.salePrice = salePrice; + this.data.item.printInBill = v.printInBill || false; + + this.dialogRef.close(this.data.item); + } +} diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.css b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.css new file mode 100644 index 0000000..d4041d9 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.css @@ -0,0 +1,11 @@ +.nutrition-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + align-items: stretch; + justify-items: stretch; +} +.nutrition-grid > * { + width: 100%; + box-sizing: border-box; /* helps with padding */ +} diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html new file mode 100644 index 0000000..edfad6a --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html @@ -0,0 +1,149 @@ +

Bundle

+ +
+
+ + Name + + + + + Units + + +
+ +
+ + Sale Price + + Computed from bundle items + + + + Menu Category + + @for (mc of menuCategories; track mc) { + + {{ mc.name }} + + } + + + + + Sale Category + + @for (sc of saleCategories; track sc) { + + {{ sc.name }} + + } + + +
+
+ Sale Category is used for Tax Report and displaying in the right bill regimes. For the Sale Report, Discount + Report and Beer Sale Report, the Sale Category from the Bundle Items is used. +
+
+ Has Happy Hour? + Not Available? +
+ +

Items

+ +
+ + Product + + + @for (p of itemProducts | async; track p) { + {{ p.name }} + } + + + + + Quantity + + + + + Sale Price + + + Print in Bill? + + +
+
+ +
+ + + + Item + + {{ row.name }} + + + + + + Qty + + {{ row.quantity }} + + + + + + Sale Price + + {{ row.salePrice | currency: 'INR' }} + + + + + + Print in Bill + + + {{ row.printInBill ? 'visibility' : 'visibility_off' }} + + + + + + + Action + + + + + + + + + +
+ +
+ + @if (!!item.id) { + + } +
diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts new file mode 100644 index 0000000..1cf201f --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { BundleDetailComponent } from './bundle-detail.component'; + +describe('BundleDetailComponent', () => { + let component: BundleDetailComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [BundleDetailComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BundleDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts new file mode 100644 index 0000000..6b5a60d --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts @@ -0,0 +1,349 @@ +import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatOptionModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged, Observable, switchMap } from 'rxjs'; + +import { MenuCategory } from '../../core/menu-category'; +import { ProductQuery } from '../../core/product-query'; +import { SaleCategory } from '../../core/sale-category'; +import { ProductService } from '../../product/product.service'; +import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; +import { Bundle, BundleItem } from '../bundle'; +import { BundleService } from '../bundle.service'; +import { BundleDetailDatasource } from './bundle-detail-datasource'; +import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; + +@Component({ + selector: 'app-bundle-detail', + templateUrl: './bundle-detail.component.html', + styleUrls: ['./bundle-detail.component.css'], + imports: [ + AsyncPipe, + CurrencyPipe, + MatAutocompleteModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + ], +}) +export class BundleDetailComponent implements OnInit, AfterViewInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + private ser = inject(BundleService); + private productSer = inject(ProductService); + + @ViewChild('name', { static: true }) nameElement?: ElementRef; + + form: FormGroup<{ + // Bundle header fields + name: FormControl; + units: FormControl; + salePrice: FormControl; + menuCategory: FormControl; + saleCategory: FormControl; + hasHappyHour: FormControl; + isNotAvailable: FormControl; + + // Item add row + addRow: FormGroup<{ + itemId: FormControl; + quantity: FormControl; + salePrice: FormControl; + printInBill: FormControl; + }>; + }>; + + menuCategories: MenuCategory[] = []; + saleCategories: SaleCategory[] = []; + public items$ = new BehaviorSubject([]); + dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$); + + item: Bundle = new Bundle(); + + itemProduct: ProductQuery | null = null; + itemProducts: Observable; + + displayedColumns = ['name', 'quantity', 'salePrice', 'printInBill', 'action']; + + constructor() { + this.form = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + units: new FormControl('', { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + menuCategory: new FormControl('', { nonNullable: true }), + saleCategory: new FormControl('', { nonNullable: true }), + hasHappyHour: new FormControl(false, { nonNullable: true }), + isNotAvailable: new FormControl(false, { nonNullable: true }), + + addRow: new FormGroup({ + itemId: new FormControl('', { nonNullable: true }), + quantity: new FormControl(1, { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + printInBill: new FormControl(true, { nonNullable: true }), + }), + }); + this.itemProducts = this.form.controls.addRow.controls.itemId.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => { + // if user types, x is string; if user selects, x is Product + if (typeof x !== 'string') { + return observableOf([]); + } + return x.trim() === '' ? observableOf([]) : this.productSer.query(x); + }), + ); + this.form.controls.addRow.controls.itemId.valueChanges.subscribe((x) => { + if (typeof x === 'string') { + this.itemProduct = null; + } + }); + } + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { + item: Bundle; + menuCategories: MenuCategory[]; + saleCategories: SaleCategory[]; + }; + this.menuCategories = data.menuCategories; + this.saleCategories = data.saleCategories; + this.showItem(data.item); + }); + } + + displayFn(product?: ProductQuery | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + bundleSelected(event: MatAutocompleteSelectedEvent): void { + const product = event.option.value as ProductQuery; + this.itemProduct = product; + this.form.controls.addRow.controls.salePrice.setValue(product.price ?? 0); + } + + private recalcBundleSalePrice(): void { + const total = (this.item.items ?? []).reduce((sum, x) => { + const qty = Number(x.quantity ?? 0); + const price = Number(x.salePrice ?? 0); + if (Number.isNaN(qty) || Number.isNaN(price)) return sum; + return sum + qty * price; + }, 0); + + // keep model + form in sync + this.item.salePrice = Number(total.toFixed(2)); + + // if the control is disabled, use patchValue + this.form.controls.salePrice.patchValue(this.item.salePrice); + } + + showItem(item: Bundle) { + this.item = item; + + this.form.setValue({ + name: this.item.name ?? '', + units: this.item.units ?? '', + salePrice: Number(this.item.salePrice ?? 0), + menuCategory: this.item.menuCategory?.id ?? '', + saleCategory: this.item.saleCategory?.id ?? '', + hasHappyHour: this.item.hasHappyHour ?? false, + isNotAvailable: this.item.isNotAvailable ?? false, + + addRow: { + itemId: '', + quantity: 1, + salePrice: 0, + printInBill: true, + }, + }); + this.itemProduct = null; + this.form.controls.salePrice.disable({ emitEvent: false }); + + this.items$.next(this.item.items ?? []); + this.recalcBundleSalePrice(); + } + + ngAfterViewInit() { + setTimeout(() => { + if (this.nameElement !== undefined) { + this.nameElement.nativeElement.focus(); + } + }, 0); + } + + resetAddRow() { + this.form.controls.addRow.reset(); + this.itemProduct = null; + } + + addRow() { + const v = this.form.value.addRow; + if (!v) { + return; + } + + if (!this.itemProduct?.id) { + this.snackBar.open('Please select a product', 'Error'); + return; + } + + const quantity = Number(v.quantity ?? 0); + if (Number.isNaN(quantity) || quantity <= 0) { + this.snackBar.open('Quantity has to be > 0', 'Error'); + return; + } + + const salePrice = Number(v.salePrice ?? 0); + if (Number.isNaN(salePrice) || salePrice < 0) { + this.snackBar.open('Sale Price has to be >= 0', 'Error'); + return; + } + + // name is expected from backend as "name (units)" on GET; + // for new rows we keep it blank (or you can set placeholder) + const bi = new BundleItem({ + itemId: this.itemProduct?.id, + name: this.itemProduct?.name ?? '', + quantity, + salePrice, + printInBill: v.printInBill || false, + }); + + this.item.items.push(bi); + this.items$.next(this.item.items); + this.resetAddRow(); + + this.recalcBundleSalePrice(); + } + + editRow(row: BundleItem) { + const dialogRef = this.dialog.open(BundleDetailDialogComponent, { + width: '650px', + data: { + item: JSON.parse(JSON.stringify(row)) as BundleItem, + }, + }); + + dialogRef.afterClosed().subscribe((result: boolean | BundleItem) => { + if (!result) { + return; + } + Object.assign(row, result as BundleItem); + this.items$.next(this.item.items); + this.resetAddRow(); + this.recalcBundleSalePrice(); + }); + } + + deleteRow(row: BundleItem) { + this.item.items.splice(this.item.items.indexOf(row), 1); + this.items$.next(this.item.items); + this.recalcBundleSalePrice(); + } + + save() { + if (!this.item.items || this.item.items.length === 0) { + this.snackBar.open('Bundle must contain at least one item', 'Error'); + return; + } + this.ser.saveOrUpdate(this.getItem()).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + this.router.navigateByUrl('/bundles'); + }, + error: (error) => { + this.snackBar.open(error, 'Error'); + }, + }); + } + + delete() { + this.ser.delete(this.item.id as string).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + this.router.navigateByUrl('/bundles'); + }, + error: (error) => { + this.snackBar.open(error, 'Error'); + }, + }); + } + + confirmDelete(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '250px', + data: { title: 'Delete Bundle?', content: 'Are you sure? This cannot be undone.' }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.delete(); + } + }); + } + + getItem(): Bundle { + const v = this.form.value; + + this.item.name = v.name ?? ''; + this.item.units = v.units ?? ''; + this.item.salePrice = Number(v.salePrice ?? 0); + this.item.hasHappyHour = v.hasHappyHour ?? false; + this.item.isNotAvailable = v.isNotAvailable ?? false; + this.item.sortOrder = this.item.sortOrder ?? 0; + + // menu category + const menuCategoryId = v.menuCategory ?? ''; + if (!menuCategoryId) { + // keep it as-is; backend will 422 anyway, but we can show UI error too + this.snackBar.open('Menu Category is required', 'Error'); + return this.item; + } + + if (this.item.menuCategory === null || this.item.menuCategory === undefined) { + this.item.menuCategory = new MenuCategory(); + } + this.item.menuCategory.id = menuCategoryId; + + // sale category + const saleCategoryId = v.saleCategory ?? ''; + if (!saleCategoryId) { + // keep it as-is; backend will 422 anyway, but we can show UI error too + this.snackBar.open('Menu Category is required', 'Error'); + return this.item; + } + + if (this.item.saleCategory === null || this.item.saleCategory === undefined) { + this.item.saleCategory = new SaleCategory(); + } + this.item.saleCategory.id = saleCategoryId; + + // ensure items array exists + if (!this.item.items) { + this.item.items = []; + } + + return this.item; + } +} diff --git a/bookie/src/app/bundles/bundle-list.resolver.spec.ts b/bookie/src/app/bundles/bundle-list.resolver.spec.ts new file mode 100644 index 0000000..ce1ad04 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list.resolver.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { BundleListResolver } from './bundle-list-resolver.service'; + +describe('BundleListResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BundleListResolver], + }); + }); + + it('should be created', inject([BundleListResolver], (service: BundleListResolver) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/bundles/bundle-list.resolver.ts b/bookie/src/app/bundles/bundle-list.resolver.ts new file mode 100644 index 0000000..a79a399 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list.resolver.ts @@ -0,0 +1,9 @@ +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; + +import { Bundle } from './bundle'; +import { BundleService } from './bundle.service'; + +export const bundleListResolver: ResolveFn = () => { + return inject(BundleService).list(); +}; diff --git a/bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts b/bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts new file mode 100644 index 0000000..3cc39af --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts @@ -0,0 +1,67 @@ +import { DataSource } from '@angular/cdk/collections'; +import { merge, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { Bundle } from '../bundle'; + +export class BundleListDataSource extends DataSource { + public data: Bundle[]; + public filteredData: Bundle[]; + public search: string; + public menuCategory: string; + + constructor( + private readonly searchFilter: Observable, + private readonly menuCategoryFilter: Observable, + private readonly dataObs: Observable, + ) { + super(); + this.data = []; + this.filteredData = []; + this.search = ''; + this.menuCategory = ''; + } + + connect(): Observable { + const dataMutations = [ + this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + ), + this.searchFilter.pipe( + tap((x) => { + this.search = x; + }), + ), + this.menuCategoryFilter.pipe( + tap((x) => { + this.menuCategory = x; + }), + ), + ]; + + return merge(...dataMutations).pipe( + map(() => this.getFilteredData(this.data, this.search, this.menuCategory)), + tap((x) => { + this.filteredData = x; + }), + ); + } + + disconnect() {} + + private getFilteredData(data: Bundle[], search: string, menuCategory: string): Bundle[] { + const tokens = (search ?? '').toLowerCase().split(/\s+/).filter(Boolean); + + return data.filter((b: Bundle) => { + const mcName = b.menuCategory?.name ?? ''; + const hay = `${b.name ?? ''} ${b.units ?? ''} ${mcName}`.toLowerCase(); + + const matchesSearch = tokens.length === 0 || tokens.every((t) => hay.includes(t)); + const matchesMenuCategory = menuCategory === '' || (b.menuCategory?.id ?? '') === menuCategory; + + return matchesSearch && matchesMenuCategory; + }); + } +} diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.css b/bookie/src/app/bundles/bundle-list/bundle-list.component.css new file mode 100644 index 0000000..d237630 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.css @@ -0,0 +1,35 @@ +.right { + display: flex; + justify-content: flex-end; +} + +.material-icons { + vertical-align: middle; +} + +.mat-column-name { + margin-right: 4px; +} + +.mat-column-price, +.mat-column-menuCategory, +.mat-column-saleCategory, +.mat-column-info { + margin-left: 4px; + margin-right: 4px; +} + +.mat-column-quantity { + margin-left: 4px; +} + +.items-list { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; +} + +.item-name { + white-space: nowrap; +} diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.html b/bookie/src/app/bundles/bundle-list/bundle-list.component.html new file mode 100644 index 0000000..8eceec2 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.html @@ -0,0 +1,105 @@ +

+ Bundles + + + add_box + Add + +

+ +
+
+ + Filter + + + + + Menu Category + + -- All Bundles -- + @for (mc of menuCategories; track mc) { + + {{ mc.name }} + + } + + +
+
+ + + + + Name + + {{ row.name }} ({{ row.units }}) + + + + + + Price + + {{ row.salePrice | currency: 'INR' }} + + + + + + Menu Category + + {{ row.menuCategory?.name }} + + + + + + Sale Category + + {{ row.saleCategory?.name }} + + + + + + Items + +
+ @for (item of row.items; track item) { + + {{ item.printInBill ? 'visibility' : 'visibility_off' }} + +
{{ item.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}
+ } +
+
+
+ + + + Details + +
+
+ + {{ row.hasHappyHour ? 'sentiment_satisfied_alt' : 'sentiment_dissatisfied' }} + + {{ row.hasHappyHour ? 'Happy Hour' : 'Regular' }} +
+ +
+ + {{ row.isNotAvailable ? 'pause' : 'play_arrow' }} + + {{ row.isNotAvailable ? 'Not Available' : 'Available' }} +
+
+
+
+ + + +
diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts b/bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts new file mode 100644 index 0000000..233b1eb --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; + +import { BundleListComponent } from './bundle-list.component'; + +describe('BundleListComponent', () => { + let component: BundleListComponent; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [BundleListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BundleListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.ts b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts new file mode 100644 index 0000000..a09ff96 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts @@ -0,0 +1,118 @@ +import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatOptionModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { MenuCategory } from '../../core/menu-category'; +import { ToCsvService } from '../../shared/to-csv.service'; +import { Bundle } from '../bundle'; +import { BundleListDataSource } from './bundle-list-datasource'; + +@Component({ + selector: 'app-bundle-list', + templateUrl: './bundle-list.component.html', + styleUrls: ['./bundle-list.component.css'], + imports: [ + AsyncPipe, + CurrencyPipe, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class BundleListComponent implements OnInit { + private route = inject(ActivatedRoute); + private toCsv = inject(ToCsvService); + + searchFilter = new BehaviorSubject(''); + menuCategoryFilter = new BehaviorSubject(''); + data: BehaviorSubject = new BehaviorSubject([]); + dataSource: BundleListDataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data); + + form: FormGroup<{ + menuCategory: FormControl; + filter: FormControl; + }>; + + list: Bundle[] = []; + menuCategories: MenuCategory[] = []; + displayedColumns: string[] = ['name', 'price', 'menuCategory', 'saleCategory', 'info', 'items']; + + constructor() { + this.form = new FormGroup({ + menuCategory: new FormControl(''), + filter: new FormControl('', { nonNullable: true }), + }); + + this.data.subscribe((data: Bundle[]) => { + this.list = data; + }); + + this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged()).subscribe((value) => { + this.searchFilter.next(value ?? ''); + }); + } + + filterOn(val: string) { + this.menuCategoryFilter.next(val); + } + + ngOnInit() { + this.dataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data); + this.route.data.subscribe((value) => { + const data = value as { list: Bundle[]; menuCategories: MenuCategory[] }; + this.loadData(data.list, data.menuCategories); + }); + } + + loadData(list: Bundle[], menuCategories: MenuCategory[]) { + this.menuCategories = menuCategories; + this.data.next(list); + } + + exportCsv() { + const headers = { + Name: 'name', + Units: 'units', + Price: 'salePrice', + MenuCategory: 'menuCategory', + SaleCategory: 'saleCategory', + Items: 'items', + }; + + // Map items to count for csv (simple) + const csvList = this.dataSource.filteredData.map((b) => ({ + ...b, + menuCategory: b.menuCategory?.name ?? '', + items: (b.items ?? []) + .map((i) => `${i.name}${i.quantity && i.quantity !== 1 ? ` x${i.quantity}` : ''}`) + .join(' | '), + })); + + const csvData = new Blob([this.toCsv.toCsv(headers, csvList as unknown as Bundle[])], { + type: 'text/csv;charset=utf-8;', + }); + + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(csvData); + link.setAttribute('download', 'bundles.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +} diff --git a/bookie/src/app/bundles/bundle.resolver.spec.ts b/bookie/src/app/bundles/bundle.resolver.spec.ts new file mode 100644 index 0000000..ff6bd4c --- /dev/null +++ b/bookie/src/app/bundles/bundle.resolver.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { BundleResolver } from './bundle-resolver.service'; + +describe('BundleResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BundleResolver], + }); + }); + + it('should be created', inject([BundleResolver], (service: BundleResolver) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/bundles/bundle.resolver.ts b/bookie/src/app/bundles/bundle.resolver.ts new file mode 100644 index 0000000..f555809 --- /dev/null +++ b/bookie/src/app/bundles/bundle.resolver.ts @@ -0,0 +1,10 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; + +import { Bundle } from './bundle'; +import { BundleService } from './bundle.service'; + +export const bundleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const id = route.paramMap.get('id'); + return inject(BundleService).get(id); +}; diff --git a/bookie/src/app/bundles/bundle.service.spec.ts b/bookie/src/app/bundles/bundle.service.spec.ts new file mode 100644 index 0000000..fe374b4 --- /dev/null +++ b/bookie/src/app/bundles/bundle.service.spec.ts @@ -0,0 +1,15 @@ +import { inject, TestBed } from '@angular/core/testing'; + +import { BundleService } from './bundle.service'; + +describe('BundleService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [BundleService], + }); + }); + + it('should be created', inject([BundleService], (service: BundleService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/bookie/src/app/bundles/bundle.service.ts b/bookie/src/app/bundles/bundle.service.ts new file mode 100644 index 0000000..79dcae9 --- /dev/null +++ b/bookie/src/app/bundles/bundle.service.ts @@ -0,0 +1,58 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; +import { Bundle } from './bundle'; + +const httpOptions = { + headers: new HttpHeaders({ 'Content-Type': 'application/json' }), +}; + +const url = '/api/bundles'; +const serviceName = 'BundleService'; + +@Injectable({ providedIn: 'root' }) +export class BundleService { + private http = inject(HttpClient); + private log = inject(ErrorLoggerService); + + list(): Observable { + return this.http + .get(`${url}/list`) + .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; + } + + get(id: string | null): Observable { + const getUrl: string = id === null ? `${url}` : `${url}/${id}`; + return this.http + .get(getUrl) + .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; + } + + save(bundle: Bundle): Observable { + return this.http + .post(`${url}`, bundle, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; + } + + update(bundle: Bundle): Observable { + return this.http + .put(`${url}/${bundle.id}`, bundle, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; + } + + saveOrUpdate(bundle: Bundle): Observable { + if (!bundle.versionId) { + return this.save(bundle); + } + return this.update(bundle); + } + + delete(id: string): Observable { + return this.http + .delete(`${url}/${id}`, httpOptions) + .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; + } +} diff --git a/bookie/src/app/bundles/bundle.ts b/bookie/src/app/bundles/bundle.ts new file mode 100644 index 0000000..5f8cb25 --- /dev/null +++ b/bookie/src/app/bundles/bundle.ts @@ -0,0 +1,52 @@ +import { MenuCategory } from '../core/menu-category'; +import { SaleCategory } from '../core/sale-category'; + +export class BundleItem { + id?: string; + itemId: string; + name: string; + salePrice: number; + quantity: number; + printInBill: boolean; + + public constructor(init?: Partial) { + this.itemId = ''; + this.name = ''; + this.salePrice = 0; + this.quantity = 0; + this.printInBill = true; + Object.assign(this, init); + } +} + +export class Bundle { + // backend: id_ (sku id), version_id (skuVersion id) + id?: string; + versionId?: string; + + name: string; + units: string; + salePrice: number; + + hasHappyHour: boolean; + isNotAvailable: boolean; + sortOrder: number; + + menuCategory?: MenuCategory | null; + saleCategory?: SaleCategory | null; + items: BundleItem[]; + + public constructor(init?: Partial) { + this.name = ''; + this.units = ''; + this.salePrice = 0; + + this.hasHappyHour = false; + this.isNotAvailable = false; + this.sortOrder = 0; + + this.menuCategory = null; + this.items = []; + Object.assign(this, init); + } +} diff --git a/bookie/src/app/bundles/bundles.routes.ts b/bookie/src/app/bundles/bundles.routes.ts new file mode 100644 index 0000000..3e8c316 --- /dev/null +++ b/bookie/src/app/bundles/bundles.routes.ts @@ -0,0 +1,44 @@ +import { Routes } from '@angular/router'; + +import { authGuard } from '../auth/auth-guard.service'; +import { menuCategoryListResolver } from '../menu-category/menu-category-list.resolver'; +import { saleCategoryListResolver } from '../sale-category/sale-category-list.resolver'; +import { BundleDetailComponent } from './bundle-detail/bundle-detail.component'; +import { bundleListResolver } from './bundle-list.resolver'; +import { BundleListComponent } from './bundle-list/bundle-list.component'; +import { bundleResolver } from './bundle.resolver'; + +export const routes: Routes = [ + { + path: '', + component: BundleListComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, // change if you have a separate permission + resolve: { + list: bundleListResolver, + menuCategories: menuCategoryListResolver, + }, + }, + { + path: 'new', + component: BundleDetailComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, + resolve: { + item: bundleResolver, // returns new Bundle() because id === null (no param here) + menuCategories: menuCategoryListResolver, + saleCategories: saleCategoryListResolver, + }, + }, + { + path: ':id', + component: BundleDetailComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, + resolve: { + item: bundleResolver, + menuCategories: menuCategoryListResolver, + saleCategories: saleCategoryListResolver, + }, + }, +]; 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 72571d0..0000000 --- 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/stock-keeping-unit.ts b/bookie/src/app/core/product-query.ts similarity index 54% rename from bookie/src/app/core/stock-keeping-unit.ts rename to bookie/src/app/core/product-query.ts index 09276e0..effb740 100644 --- a/bookie/src/app/core/stock-keeping-unit.ts +++ b/bookie/src/app/core/product-query.ts @@ -2,40 +2,46 @@ import { MenuCategory } from './menu-category'; import { SaleCategory } from './sale-category'; import { Tax } from './tax'; -export class StockKeepingUnit { - id: string | undefined; - versionId?: string; +export class BundleItemQuery { + id: string; name: string; - units: string; - menuCategory?: MenuCategory; - saleCategory?: SaleCategory; price: number; - hasHappyHour: boolean; - isNotAvailable: boolean; quantity: number; - isActive: boolean; - sortOrder: number; - - enabled: boolean; tax: Tax; - validFrom: string | null; - validTill: string | null; - - public constructor(init?: Partial) { - this.id = undefined; + public constructor(init?: Partial) { + this.id = ''; this.name = ''; - this.units = ''; this.price = 0; - this.hasHappyHour = false; - this.isNotAvailable = false; - this.quantity = 0; - this.isActive = true; - this.sortOrder = 0; - this.enabled = true; - this.validFrom = null; - this.validTill = null; + this.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; + this.name = ''; + 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/customers/customer-detail/customer-detail.component.ts b/bookie/src/app/customers/customer-detail/customer-detail.component.ts index b658da9..79b8702 100644 --- a/bookie/src/app/customers/customer-detail/customer-detail.component.ts +++ b/bookie/src/app/customers/customer-detail/customer-detail.component.ts @@ -21,7 +21,6 @@ import { CustomerService } from '../customer.service'; styleUrls: ['./customer-detail.component.sass'], imports: [ MatButtonModule, - MatCheckboxModule, MatDividerModule, MatFormFieldModule, diff --git a/bookie/src/app/home/home.component.html b/bookie/src/app/home/home.component.html index 73c49ff..71a0354 100644 --- a/bookie/src/app/home/home.component.html +++ b/bookie/src/app/home/home.component.html @@ -76,6 +76,11 @@

Products

} + @if (auth.allowed('products')) { + +

Bundles

+
+ } @if (auth.allowed('temporal-products')) {

Temporal Products

diff --git a/bookie/src/app/modifier-categories/modifier-category.service.ts b/bookie/src/app/modifier-categories/modifier-category.service.ts index c9d64ef..199dab0 100644 --- a/bookie/src/app/modifier-categories/modifier-category.service.ts +++ b/bookie/src/app/modifier-categories/modifier-category.service.ts @@ -32,9 +32,9 @@ export class ModifierCategoryService { .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; } - listForSku(id: string): Observable { + listForSku(sku_id: string): Observable { return this.http - .get(`${url}/for-product/${id}`) + .get(`${url}/for-sku/${sku_id}`) .pipe(catchError(this.log.handleError(serviceName, 'listForSku'))) as Observable; } diff --git a/bookie/src/app/product/product-list/product-list.component.ts b/bookie/src/app/product/product-list/product-list.component.ts index 7ba617b..b46fb57 100644 --- a/bookie/src/app/product/product-list/product-list.component.ts +++ b/bookie/src/app/product/product-list/product-list.component.ts @@ -10,7 +10,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTableModule } from '@angular/material/table'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute, RouterModule } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; @@ -37,7 +37,7 @@ import { ProductListDataSource } from './product-list-datasource'; MatSelectModule, MatTableModule, ReactiveFormsModule, - RouterLink, + RouterModule, ], }) export class ProductListComponent implements OnInit { diff --git a/bookie/src/app/product/product.service.ts b/bookie/src/app/product/product.service.ts index 0e6b8f7..a3aa8e1 100644 --- a/bookie/src/app/product/product.service.ts +++ b/bookie/src/app/product/product.service.ts @@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators'; import { ErrorLoggerService } from '../core/error-logger.service'; import { Product } from '../core/product'; +import { ProductQuery } from '../core/product-query'; const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json' }), @@ -31,18 +32,25 @@ export class ProductService { .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; } - listOfSaleCategory(id: string): Observable { + listOfSaleCategory(id: string): Observable { const options = { params: new HttpParams().set('sc', id) }; return this.http - .get(`${url}/query`, options) - .pipe(catchError(this.log.handleError(serviceName, 'listOfSaleCategory'))) as Observable; + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'listOfSaleCategory'))) as Observable; } - listIsActiveOfCategory(id: string): Observable { + listIsActiveOfCategory(id: string): Observable { const options = { params: new HttpParams().set('mc', id) }; return this.http - .get(`${url}/query`, options) - .pipe(catchError(this.log.handleError(serviceName, 'listIsActiveOfCategory'))) as Observable; + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'listIsActiveOfCategory'))) as Observable; + } + + query(query: string): Observable { + const options = { params: new HttpParams().set('q', query) }; + return this.http + .get(`${url}/query`, options) + .pipe(catchError(this.log.handleError(serviceName, 'query'))) as Observable; } save(product: Product): Observable { @@ -75,11 +83,4 @@ export class ProductService { .delete(`${url}/${id}`, httpOptions) .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; } - - balance(id: string, date: string): Observable { - const options = { params: new HttpParams().set('d', date) }; - return this.http - .get(`${url}/balance`, options) - .pipe(catchError(this.log.handleError(serviceName, 'balance'))) as Observable; - } } diff --git a/bookie/src/app/sale-category/product-list.resolver.ts b/bookie/src/app/sale-category/product-list.resolver.ts index 9ec4417..015cd0d 100644 --- a/bookie/src/app/sale-category/product-list.resolver.ts +++ b/bookie/src/app/sale-category/product-list.resolver.ts @@ -2,10 +2,10 @@ import { inject } from '@angular/core'; import { ResolveFn } from '@angular/router'; import { of as observableOf } from 'rxjs'; -import { Product } from '../core/product'; +import { ProductQuery } from '../core/product-query'; import { ProductService } from '../product/product.service'; -export const productListResolver: ResolveFn = (route) => { +export const productListResolver: ResolveFn = (route) => { const id = route.paramMap.get('id'); if (id === null) { return observableOf([]); diff --git a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts index 4a462e9..217a32d 100644 --- a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts +++ b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail-datasource.ts @@ -2,12 +2,12 @@ import { DataSource } from '@angular/cdk/collections'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; -import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; +import { ProductQuery } from '../../core/product-query'; -export class SaleCategoryDetailDatasource extends DataSource { - private data: Product[] = []; +export class SaleCategoryDetailDatasource extends DataSource { + private data: ProductQuery[] = []; - constructor(private readonly dataObs: Observable) { + constructor(private readonly dataObs: Observable) { super(); this.dataObs = dataObs.pipe( tap((x) => { @@ -16,7 +16,7 @@ export class SaleCategoryDetailDatasource extends DataSource { ); } - connect(): Observable { + connect(): Observable { return this.dataObs; } diff --git a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts index 25c92ef..5311933 100644 --- a/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts +++ b/bookie/src/app/sale-category/sale-category-detail/sale-category-detail.component.ts @@ -14,8 +14,8 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { round } from 'mathjs'; import { BehaviorSubject } from 'rxjs'; +import { ProductQuery } from '../../core/product-query'; import { SaleCategory } from '../../core/sale-category'; -import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; import { Tax } from '../../core/tax'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { SaleCategoryService } from '../sale-category.service'; @@ -55,7 +55,7 @@ export class SaleCategoryDetailComponent implements OnInit, AfterViewInit { taxes: Tax[] = []; item: SaleCategory = new SaleCategory(); - products: BehaviorSubject = new BehaviorSubject([]); + products: BehaviorSubject = new BehaviorSubject([]); dataSource: SaleCategoryDetailDatasource = new SaleCategoryDetailDatasource(this.products); displayedColumns = ['name', 'price', 'menuCategory']; @@ -70,7 +70,7 @@ export class SaleCategoryDetailComponent implements OnInit, AfterViewInit { ngOnInit() { this.route.data.subscribe((value) => { - const data = value as { item: SaleCategory; taxes: Tax[]; products: Product[] }; + const data = value as { item: SaleCategory; taxes: Tax[]; products: ProductQuery[] }; this.showItem(data.item); this.taxes = data.taxes; this.products.next(data.products); diff --git a/bookie/src/app/sales/bill.service.ts b/bookie/src/app/sales/bill.service.ts index f8d597d..2c78bbb 100644 --- a/bookie/src/app/sales/bill.service.ts +++ b/bookie/src/app/sales/bill.service.ts @@ -3,17 +3,17 @@ 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 { StockKeepingUnit } from '../core/stock-keeping-unit'; import { Table } from '../core/table'; import { ModifierCategoryService } from '../modifier-categories/modifier-category.service'; import { MathService } from '../shared/math.service'; import { Bill } from './bills/bill'; +import { BillRow } from './bills/bill-row'; import { Inventory } from './bills/inventory'; import { Kot } from './bills/kot'; import { VoucherType } from './bills/voucher-type'; @@ -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,34 +135,57 @@ 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: StockKeepingUnit, 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, quantity, price: sku.price, isHappyHour: sku.hasHappyHour, - taxRate: sku.tax.rate, + taxRate: sku.tax?.rate, 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 0000000..3bbcfea --- /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 1ab01d3..0000000 --- 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 9119d3b..dd418a2 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 16982df..0b01f56 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 1cf8c3f..9f9ee18 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 d57f736..686322d 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 af8eab8..26d3b00 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 { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; +import { ProductQuery } from '../../core/product-query'; import { Tax } from '../../core/tax'; export class Inventory { id: string | undefined; - sku: Product; + 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 Product(); + 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/bills/product-link.ts b/bookie/src/app/sales/bills/product-link.ts new file mode 100644 index 0000000..3605a45 --- /dev/null +++ b/bookie/src/app/sales/bills/product-link.ts @@ -0,0 +1,21 @@ +import { SaleCategory } from '../../core/sale-category'; +import { Tax } from '../../core/tax'; + +export class ProductLink { + id: string | undefined; + name: string; + price: number; + tax: Tax | undefined; + saleCategory: SaleCategory | undefined; + hasHappyHour: boolean; + enabled: boolean; + + public constructor(init?: Partial) { + this.id = undefined; + this.name = ''; + this.price = 0; + this.hasHappyHour = false; + this.enabled = false; + 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 0e046ba..2b149fb 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 dd28522..4ed0a8c 100644 --- a/bookie/src/app/sales/products/products.component.ts +++ b/bookie/src/app/sales/products/products.component.ts @@ -4,8 +4,8 @@ import { MatCardModule } from '@angular/material/card'; import { MatRippleModule } from '@angular/material/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ProductQuery } from '../../core/product-query'; import { BillService } from '../bill.service'; -import { Product } from '../product'; @Component({ selector: 'app-products', @@ -17,19 +17,19 @@ export class ProductsComponent implements OnInit { private route = inject(ActivatedRoute); private bs = inject(BillService); - list: Product[] = []; + list: ProductQuery[] = []; ngOnInit() { this.route.data.subscribe((value) => { - const data = value as { list: Product[] }; + const data = value as { list: ProductQuery[] }; this.list = data.list; }); } - addSku(product: Product): void { + addSku(product: ProductQuery): void { if (product.isNotAvailable) { return; } - this.bs.addSku(product, 1, 0); + this.bs.addOneSku(product); } } diff --git a/bookie/src/app/sales/products/products.resolver.ts b/bookie/src/app/sales/products/products.resolver.ts index 213338e..91e941b 100644 --- a/bookie/src/app/sales/products/products.resolver.ts +++ b/bookie/src/app/sales/products/products.resolver.ts @@ -1,10 +1,10 @@ import { inject } from '@angular/core'; import { ResolveFn } from '@angular/router'; -import { Product } from '../../core/product'; +import { ProductQuery } from '../../core/product-query'; import { ProductService } from '../../product/product.service'; -export const productsResolver: ResolveFn = (route) => { +export const productsResolver: ResolveFn = (route) => { const id = route.paramMap.get('id') as string; return inject(ProductService).listIsActiveOfCategory(id); }; 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 9741c6e..fc33df0 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/app/update-product-prices/update-product-prices.service.ts b/bookie/src/app/update-product-prices/update-product-prices.service.ts index f841c1d..b4d8652 100644 --- a/bookie/src/app/update-product-prices/update-product-prices.service.ts +++ b/bookie/src/app/update-product-prices/update-product-prices.service.ts @@ -4,7 +4,6 @@ import { Observable } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { ErrorLoggerService } from '../core/error-logger.service'; -import { StockKeepingUnit as Product } from '../core/stock-keeping-unit'; import { UpdateProductPrices } from './update-product-prices'; const url = '/api/update-product-prices'; @@ -31,7 +30,7 @@ export class UpdateProductPricesService { save(item: UpdateProductPrices): Observable { const saveUrl: string = item.menuCategoryId === null ? url : `${url}/${item.menuCategoryId}`; return this.http - .post(saveUrl, item) + .post(saveUrl, item) .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; } } diff --git a/bookie/src/layout.sass b/bookie/src/layout.sass index ed4ceed..25bba9f 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 623972d..628bb9d 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 34f1946..1d70917 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 diff --git a/frank/config.py b/frank/config.py index 08cb2fe..fac9208 100644 --- a/frank/config.py +++ b/frank/config.py @@ -1,5 +1,5 @@ from dotenv import load_dotenv -from pydantic import BaseSettings +from pydantic_settings import BaseSettings class Settings(BaseSettings): diff --git a/frank/pyproject.toml b/frank/pyproject.toml index ddf0e30..79a9e33 100644 --- a/frank/pyproject.toml +++ b/frank/pyproject.toml @@ -5,10 +5,11 @@ description = "Point of Sale for a restaurant" authors = ["tanshu "] [tool.poetry.dependencies] -python = "^3.10" -pydantic = {extras = ["dotenv"], version = "^1.9.1"} -arq = "^0.23a1" -aiohttp = "^3.8.1" +python = "^3.14" +pydantic = {extras = ["dotenv"], version = "^2.11.7"} +pydantic-settings = "^2.12.0" +arq = "^0.26.3" +aiohttp = "^3.12.13" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/frank/run_worker.py b/frank/run_worker.py new file mode 100644 index 0000000..b32deb9 --- /dev/null +++ b/frank/run_worker.py @@ -0,0 +1,9 @@ +import asyncio +from arq.worker import run_worker +from worker import WorkerSettings + +# Create & set a loop BEFORE arq touches asyncio.get_event_loop() +asyncio.set_event_loop(asyncio.new_event_loop()) + + +run_worker(WorkerSettings) diff --git a/frank/worker-start.sh b/frank/worker-start.sh index c4572cb..d9c21c3 100755 --- a/frank/worker-start.sh +++ b/frank/worker-start.sh @@ -1,5 +1,5 @@ #! /usr/bin/env bash set -e -echo arq worker.WorkerSettings -arq worker.WorkerSettings +echo python run_worker.py +python run_worker.py