From b79af6e1c6e0b78986bb7b988f1e76dbe53ab883 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 29 Jan 2026 16:23:07 +0000 Subject: [PATCH 1/7] Changed the for-product route to for-sku to reduce ambiguity --- barker/barker/routers/modifier_category.py | 2 +- .../src/app/modifier-categories/modifier-category.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/barker/barker/routers/modifier_category.py b/barker/barker/routers/modifier_category.py index dca0745..76d0a1b 100644 --- a/barker/barker/routers/modifier_category.py +++ b/barker/barker/routers/modifier_category.py @@ -190,7 +190,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), 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; } From 6e8843d3c413718ed543d82a0841a94a3ce38873 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Sat, 31 Jan 2026 15:16:58 +0000 Subject: [PATCH 2/7] Feature: Added product bundles. Have to wire it up to inventory and reports. --- .../alembic/versions/367ecf7b898f_bundles.py | 103 ++++ barker/barker/db/base.py | 4 + barker/barker/main.py | 2 + barker/barker/models/bundle_item.py | 73 +++ barker/barker/models/inventory.py | 13 +- barker/barker/models/inventory_type.py | 7 + barker/barker/models/product_version.py | 11 +- barker/barker/models/stock_keeping_unit.py | 7 + barker/barker/routers/__init__.py | 43 ++ barker/barker/routers/bundle.py | 520 ++++++++++++++++++ barker/barker/routers/product.py | 92 +--- barker/barker/schemas/bundle.py | 55 ++ bookie/src/app/app.routes.ts | 4 + .../bundle-detail/bundle-detail-datasource.ts | 16 + .../bundle-detail-dialog.component.css | 0 .../bundle-detail-dialog.component.html | 32 ++ .../bundle-detail-dialog.component.spec.ts | 22 + .../bundle-detail-dialog.component.ts | 110 ++++ .../bundle-detail/bundle-detail.component.css | 11 + .../bundle-detail.component.html | 120 ++++ .../bundle-detail.component.spec.ts | 24 + .../bundle-detail/bundle-detail.component.ts | 325 +++++++++++ .../app/bundles/bundle-list.resolver.spec.ts | 15 + .../src/app/bundles/bundle-list.resolver.ts | 9 + .../bundle-list/bundle-list-datasource.ts | 67 +++ .../bundle-list/bundle-list.component.css | 35 ++ .../bundle-list/bundle-list.component.html | 94 ++++ .../bundle-list/bundle-list.component.spec.ts | 22 + .../bundle-list/bundle-list.component.ts | 117 ++++ .../src/app/bundles/bundle.resolver.spec.ts | 15 + bookie/src/app/bundles/bundle.resolver.ts | 10 + bookie/src/app/bundles/bundle.service.spec.ts | 15 + bookie/src/app/bundles/bundle.service.ts | 58 ++ bookie/src/app/bundles/bundle.ts | 48 ++ bookie/src/app/bundles/bundles.routes.ts | 41 ++ ...stock-keeping-unit.ts => product-query.ts} | 22 +- bookie/src/app/home/home.component.html | 5 + .../product-list/product-list.component.ts | 4 +- bookie/src/app/product/product.service.ts | 27 +- .../sale-category/product-list.resolver.ts | 4 +- .../sale-category-detail-datasource.ts | 10 +- .../sale-category-detail.component.ts | 6 +- bookie/src/app/sales/bill.service.ts | 6 +- bookie/src/app/sales/bills/inventory.ts | 6 +- bookie/src/app/sales/bills/product-link.ts | 21 + .../app/sales/products/products.component.ts | 11 +- .../app/sales/products/products.resolver.ts | 4 +- .../update-product-prices.service.ts | 3 +- 48 files changed, 2139 insertions(+), 130 deletions(-) create mode 100644 barker/alembic/versions/367ecf7b898f_bundles.py create mode 100644 barker/barker/models/bundle_item.py create mode 100644 barker/barker/models/inventory_type.py create mode 100644 barker/barker/routers/bundle.py create mode 100644 barker/barker/schemas/bundle.py create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail-datasource.ts create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.spec.ts create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail.component.css create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail.component.html create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail.component.spec.ts create mode 100644 bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts create mode 100644 bookie/src/app/bundles/bundle-list.resolver.spec.ts create mode 100644 bookie/src/app/bundles/bundle-list.resolver.ts create mode 100644 bookie/src/app/bundles/bundle-list/bundle-list-datasource.ts create mode 100644 bookie/src/app/bundles/bundle-list/bundle-list.component.css create mode 100644 bookie/src/app/bundles/bundle-list/bundle-list.component.html create mode 100644 bookie/src/app/bundles/bundle-list/bundle-list.component.spec.ts create mode 100644 bookie/src/app/bundles/bundle-list/bundle-list.component.ts create mode 100644 bookie/src/app/bundles/bundle.resolver.spec.ts create mode 100644 bookie/src/app/bundles/bundle.resolver.ts create mode 100644 bookie/src/app/bundles/bundle.service.spec.ts create mode 100644 bookie/src/app/bundles/bundle.service.ts create mode 100644 bookie/src/app/bundles/bundle.ts create mode 100644 bookie/src/app/bundles/bundles.routes.ts rename bookie/src/app/core/{stock-keeping-unit.ts => product-query.ts} (58%) create mode 100644 bookie/src/app/sales/bills/product-link.ts diff --git a/barker/alembic/versions/367ecf7b898f_bundles.py b/barker/alembic/versions/367ecf7b898f_bundles.py new file mode 100644 index 0000000..58cbe8f --- /dev/null +++ b/barker/alembic/versions/367ecf7b898f_bundles.py @@ -0,0 +1,103 @@ +"""bundles + +Revision ID: 367ecf7b898f +Revises: 1e0daf6bc1ae +Create Date: 2026-01-28 16:11:21.874409 + +""" + +import sqlalchemy as sa + +from sqlalchemy.dialects import postgresql + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "367ecf7b898f" +down_revision = "1e0daf6bc1ae" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "bundle_items", + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("bundle_id", sa.Uuid(), nullable=False), + sa.Column("item_id", sa.Uuid(), nullable=False), + sa.Column("quantity", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("sale_price", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_till", sa.Date(), nullable=True), + postgresql.ExcludeConstraint( + (sa.column("bundle_id"), "="), + (sa.column("item_id"), "="), + (sa.text("daterange(valid_from, valid_till, '[]')"), "&&"), + using="gist", + name=op.f("uq_bundle_items_bundle_id_item_id"), + ), + sa.ForeignKeyConstraint( + ["bundle_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_bundle_id_stock_keeping_units") + ), + sa.ForeignKeyConstraint( + ["item_id"], ["stock_keeping_units.id"], name=op.f("fk_bundle_items_item_id_stock_keeping_units") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_bundle_items")), + ) + + inventory_type = sa.Enum("regular", "bundle", "bundle_item", name="inventorytype") + # inventory_type.create(op.get_bind()) + op.add_column("inventories", sa.Column("type", inventory_type, server_default=sa.text("'regular'"), nullable=False)) + op.add_column("inventories", sa.Column("parent_id", sa.Uuid(), nullable=True)) + op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique") + op.create_unique_constraint( + op.f("uq_inventories_kot_id"), + "inventories", + ["kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"], + postgresql_nulls_not_distinct=True, + ) + op.create_foreign_key( + op.f("fk_inventories_parent_id_inventories"), "inventories", "inventories", ["parent_id"], ["id"] + ) + op.add_column( + "stock_keeping_units", sa.Column("is_bundle", sa.Boolean(), server_default=sa.text("false"), nullable=False) + ) + mc = sa.table( + "menu_categories", + sa.column("id", sa.Uuid()), + sa.column("name", sa.Unicode(length=255)), + sa.column("is_active", sa.Boolean()), + sa.column("is_fixture", sa.Boolean()), + sa.column("sort_order", sa.Integer()), + ) + + op.execute( + mc.insert().values( + id="6752ed9d-6f1a-4941-940a-17759dcc6720", + name="Bundles", + is_active=True, + is_fixture=True, + sort_order=0, + ) + ) + + +def downgrade(): + op.drop_table("bundle_items") + op.drop_column("stock_keeping_units", "is_bundle") + op.drop_constraint(op.f("fk_inventories_parent_id_inventories"), "inventories", type_="foreignkey") + op.drop_constraint(op.f("uq_inventories_kot_id"), "inventories", type_="unique") + op.create_unique_constraint( + op.f("uq_inventories_kot_id"), + "inventories", + ["kot_id", "sku_id", "is_happy_hour", "price"], + postgresql_nulls_not_distinct=False, + ) + op.drop_column("inventories", "parent_id") + op.drop_column("inventories", "type") + mc = sa.table( + "menu_categories", + sa.column("id", sa.Uuid()), + ) + op.execute(mc.delete().where(mc.c.id == "6752ed9d-6f1a-4941-940a-17759dcc6720")) 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..a66cba8 --- /dev/null +++ b/barker/barker/models/bundle_item.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import uuid + +from datetime import date +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Date, ForeignKey, Numeric, Uuid, func, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .stock_keeping_unit import StockKeepingUnit + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class BundleItem: + __tablename__ = "bundle_items" + + id: Mapped[uuid.UUID] = mapped_column( + Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") + ) + bundle_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + item_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + quantity: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + sale_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + + valid_from: Mapped[date | None] = mapped_column(Date(), nullable=True) + valid_till: Mapped[date | None] = mapped_column(Date(), nullable=True) + + bundle: Mapped[StockKeepingUnit] = relationship( + "StockKeepingUnit", back_populates="bundle_items", foreign_keys=[bundle_id] + ) + item: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", foreign_keys=[item_id]) + + __table_args__ = ( + postgresql.ExcludeConstraint( + (bundle_id, "="), + (item_id, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), + ) + + def __init__( + self, + quantity: Decimal, + sale_price: Decimal, + bundle_id: uuid.UUID, + item_id: uuid.UUID, + bundle: StockKeepingUnit | None = None, + item: StockKeepingUnit | None = None, + valid_from: date | None = None, + valid_till: date | None = None, + id_: uuid.UUID | None = None, + ): + self.quantity = quantity + self.sale_price = sale_price + self.valid_from = valid_from + self.valid_till = valid_till + if bundle_id is not None: + self.bundle_id = bundle_id + if item_id is not None: + self.item_id = item_id + if bundle is not None: + self.bundle = bundle + if item is not None: + self.item = item + if id_ is not None: + self.id = id_ diff --git a/barker/barker/models/inventory.py b/barker/barker/models/inventory.py index d2dea1a..979ac1b 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, @@ -21,6 +22,8 @@ from sqlalchemy import ( from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship +from barker.models.inventory_type import InventoryType + from ..db.base_class import reg from .tax import Tax @@ -34,7 +37,7 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class Inventory: __tablename__ = "inventories" - __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price"),) + __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) kot_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("kots.id"), nullable=False, index=True) @@ -46,10 +49,16 @@ class Inventory: tax_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("taxes.id"), nullable=False) discount: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False) + type_: Mapped[InventoryType] = mapped_column( + "type", Enum(InventoryType), server_default=text("regular"), nullable=False + ) + parent_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("inventories.id"), nullable=True) kot: Mapped[Kot] = relationship(back_populates="inventories") tax: Mapped[Tax] = relationship(back_populates="inventories") sku: Mapped[StockKeepingUnit] = relationship(back_populates="inventories") + parent: Mapped[Inventory | None] = relationship("Inventory", remote_side="Inventory.id", back_populates="children") + children: Mapped[list[Inventory]] = relationship("Inventory", back_populates="parent") modifiers: Mapped[list[InventoryModifier]] = relationship(back_populates="inventory") @@ -64,6 +73,7 @@ class Inventory: tax_id: uuid.UUID | None, tax_rate: Decimal, sort_order: int, + type_: InventoryType = InventoryType.regular, sku: StockKeepingUnit | None = None, tax: Tax | None = None, ): @@ -78,6 +88,7 @@ class Inventory: self.tax_id = tax_id self.tax_rate = tax_rate self.sort_order = sort_order + self.type_ = type_ if sku is not None: self.sku = sku if tax is not None: 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/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/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/routers/__init__.py b/barker/barker/routers/__init__.py index c8de974..26803f9 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -1,5 +1,12 @@ from datetime import UTC, date, datetime, timedelta +from sqlalchemy import and_, or_ + +from barker.models.product import Product +from barker.models.product_version import ProductVersion +from barker.models.sku_version import SkuVersion +from barker.models.stock_keeping_unit import StockKeepingUnit + from ..core.config import settings @@ -32,3 +39,39 @@ def dates_overlap(start1: date | None, end1: date | None, start2: date | None, e if end2 is None: end2 = date.max return start1 <= end2 and start2 <= end1 + + +def _pv_active(date_: date): + return and_( + or_(ProductVersion.valid_from == None, ProductVersion.valid_from <= date_), # noqa: E711 + or_(ProductVersion.valid_till == None, ProductVersion.valid_till >= date_), # noqa: E711 + ) + + +def _sv_active(date_: date): + return and_( + or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 + or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 + ) + + +def _pv_onclause(date_: date): + return and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + + +def _sv_onclause(date_: date): + return and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 + or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 + ) diff --git a/barker/barker/routers/bundle.py b/barker/barker/routers/bundle.py new file mode 100644 index 0000000..574e175 --- /dev/null +++ b/barker/barker/routers/bundle.py @@ -0,0 +1,520 @@ +import uuid + +from collections.abc import Sequence +from datetime import date, timedelta +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Security, status +from sqlalchemy import Date, func, or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import contains_eager + +from ..core.config import settings +from ..core.security import get_current_active_user as get_user +from ..db.session import SessionFuture +from ..models.bundle_item import BundleItem as BundleItemModel +from ..models.inventory import Inventory +from ..models.kot import Kot +from ..models.product import Product +from ..models.product_version import ProductVersion +from ..models.sku_version import SkuVersion +from ..models.stock_keeping_unit import StockKeepingUnit +from ..models.voucher import Voucher +from ..schemas import bundle as schemas +from ..schemas.menu_category import MenuCategoryLink +from ..schemas.user_token import UserToken +from . import _pv_onclause, _sv_onclause, effective_date + + +router = APIRouter() + +FRACTION_1 = Decimal("1") +YIELD_1 = Decimal("1") +COST_0 = Decimal("0") + + +# ---------- helpers ---------- + + +def bundle_blank() -> schemas.BundleBlank: + return schemas.BundleBlank( + name="", + units="", + sale_price=Decimal("0.00"), + has_happy_hour=False, + is_not_available=False, + sort_order=0, + items=[], + ) + + +def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemModel]) -> schemas.Bundle: + sku = sv.sku + return schemas.Bundle( + id_=sku.id, + version_id=sv.id, + name=pv.name, + units=pv.fraction_units, + sale_price=sv.sale_price, + has_happy_hour=sv.has_happy_hour, + is_not_available=sku.is_not_available, + sort_order=sku.sort_order, + menu_category=MenuCategoryLink( + id_=sv.menu_category_id, + name=sv.menu_category.name, + skus=[], + ) + if sv.menu_category is not None + else None, + items=[ + schemas.BundleItem( + id_=bi.id, + name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})", + item_id=bi.item_id, + sale_price=bi.sale_price, + quantity=bi.quantity, + ) + for bi in items + ], + ) + + +# ---------- routes ---------- + + +@router.post("", response_model=None) +def save( + data: schemas.BundleIn, + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + try: + with SessionFuture() as db: + if not data.items: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Not enough bundle items.", + ) + sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) + + sale_category_id = db.execute( + select(ProductVersion.sale_category_id) + .join(Product, onclause=_pv_onclause(date_)) + .join(Product.skus) + .where(StockKeepingUnit.id == data.items[0].item_id) + ).scalar_one() + + product = Product(sort_order=data.sort_order) + db.add(product) + db.flush() + + pv = ProductVersion( + product_id=None, + product=product, + name=data.name, + fraction_units=data.units, # per your rule + sale_category_id=sale_category_id, + sale_category=None, + valid_from=date_, + valid_till=None, + ) + db.add(pv) + + sku = StockKeepingUnit( + product=product, + is_not_available=data.is_not_available, + is_bundle=True, + sort_order=data.sort_order, + ) + db.add(sku) + db.flush() + + sv = SkuVersion( + sku_id=sku.id, + units=data.units, + fraction=FRACTION_1, + product_yield=YIELD_1, + cost_price=COST_0, + sale_price=sale_price, + has_happy_hour=data.has_happy_hour, + menu_category_id=data.menu_category.id_, + valid_from=date_, + valid_till=None, + ) + db.add(sv) + + # bundle items + for it in data.items: + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + quantity=round(it.quantity, 5), + sale_price=round(it.sale_price, 2), + valid_from=date_, + valid_till=None, + ) + ) + + db.commit() + + except SQLAlchemyError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.put("/{id_}", response_model=None) +def update_route( + id_: uuid.UUID, # bundle header SKU id + data: schemas.BundleIn, + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + try: + with SessionFuture() as db: + if not data.items: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Not enough bundle items.", + ) + sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) + # Load header SKU + active header SkuVersion + menu category + product + active ProductVersion + sv: SkuVersion | None = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .join(SkuVersion.menu_category) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + contains_eager(SkuVersion.menu_category), + ) + ) + .unique() + .scalars() + .one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + sku = sv.sku + product = sku.product + pv = product.versions[0] + pv_changed = pv.name != data.name + + sku.is_not_available = data.is_not_available + sku.sort_order = data.sort_order + product.sort_order = data.sort_order + + if pv_changed: + if pv.valid_from == date_: + pv.name = data.name + else: + pv.valid_till = date_ - timedelta(days=1) + db.add( + ProductVersion( + product_id=product.id, + product=product, + name=data.name, + fraction_units=pv.fraction_units, + sale_category_id=pv.sale_category_id, + valid_from=date_, + valid_till=None, + ) + ) + sv_changed = ( + sv.units != data.units + or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price + or sv.has_happy_hour != data.has_happy_hour + or sv.menu_category_id != data.menu_category.id_ + ) + if sv_changed: + if sv.valid_from == date_: + sv.units = data.units + sv.sale_price = sale_price + sv.has_happy_hour = data.has_happy_hour + sv.menu_category_id = data.menu_category.id_ + + # # enforce bundle constants + # sv.fraction = FRACTION_1 + # sv.product_yield = YIELD_1 + # sv.cost_price = COST_0 + else: + sv.valid_till = date_ - timedelta(days=1) + db.add( + SkuVersion( + sku_id=sku.id, + units=data.units, + fraction=FRACTION_1, + product_yield=YIELD_1, + cost_price=COST_0, + sale_price=sale_price, + has_happy_hour=data.has_happy_hour, + menu_category_id=data.menu_category.id_, + valid_from=date_, + valid_till=None, + ) + ) + + # ---- Bundle items replace/update + existing = db.execute(select(BundleItemModel).where(BundleItemModel.bundle_id == sku.id)).scalars().all() + existing_by_item = {x.item_id: x for x in existing} + incoming_ids = {x.item_id for x in data.items} + + existing_by_item = {x.item_id: x for x in existing} + + incoming_ids = {x.item_id for x in data.items} + + # delete removed + for ex_d in existing: + if ex_d.item_id not in incoming_ids: + if ex_d.valid_from == date_: + db.delete(ex_d) + else: + ex_d.valid_till = date_ - timedelta(days=1) + + # add/update + for it in data.items: + ex = existing_by_item.get(it.item_id) + if ex is None: + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + sale_price=round(it.sale_price, 2), + quantity=round(it.quantity, 5), + valid_from=date_, + valid_till=None, + ) + ) + elif ex.valid_from == date_: + ex.sale_price = round(it.sale_price, 2) + ex.quantity = round(it.quantity, 5) + else: + ex.valid_till = date_ - timedelta(days=1) + db.add( + BundleItemModel( + bundle_id=sku.id, + item_id=it.item_id, + sale_price=round(it.sale_price, 2), + quantity=round(it.quantity, 5), + valid_from=date_, + valid_till=None, + ) + ) + + db.commit() + + except SQLAlchemyError as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.get("", response_model=schemas.BundleBlank) +def show_blank( + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.BundleBlank: + return bundle_blank() + + +@router.get("/list", response_model=list[schemas.Bundle]) +def show_list( + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> list[schemas.Bundle]: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + + rows = ( + db.execute( + select(SkuVersion) + # .select_from(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(SkuVersion.menu_category) + .where(StockKeepingUnit.is_bundle == True) # noqa: E712 + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product), + contains_eager(SkuVersion.menu_category), + ) + .order_by(StockKeepingUnit.sort_order, SkuVersion.units) + ) + .unique() + .scalars() + .all() + ) + + out: list[schemas.Bundle] = [] + for sv in rows: + pv = sv.sku.product.versions[0] + items = ( + db.execute( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + BundleItemModel.bundle_id == sv.sku_id, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) + ) + .unique() + .scalars() + .all() + ) + out.append(_bundle_info(pv=pv, sv=sv, items=items)) + return out + + +@router.get("/{id_}", response_model=schemas.Bundle) +def show_id( + id_: uuid.UUID, # bundle header SKU id + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> schemas.Bundle: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + + sv = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .join(SkuVersion.menu_category) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product), + contains_eager(SkuVersion.menu_category), + ) + ) + .unique() + .scalar_one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + pv = sv.sku.product.versions[0] + + items = ( + db.execute( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + BundleItemModel.bundle_id == sv.sku_id, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) + ) + .unique() + .scalars() + .all() + ) + return _bundle_info(pv=pv, sv=sv, items=items) + + +@router.delete("/{id_}", response_model=None) +def delete_route( + id_: uuid.UUID, # bundle header SKU id + date_: date = Depends(effective_date), + user: UserToken = Security(get_user, scopes=["products"]), +) -> None: + with SessionFuture() as db: + sv_onclause = _sv_onclause(date_) + pv_onclause = _pv_onclause(date_) + day = func.cast( + Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + billed = db.execute( + select(func.count(Inventory.id)) + .join(Inventory.kot) + .join(Kot.voucher) + .where(Inventory.sku_id == id_, day >= date_) + ).scalar_one() + if billed > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="The cannot delete this product as it was billed", + ) + + sv = ( + db.execute( + select(SkuVersion) + .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=pv_onclause) + .where( + StockKeepingUnit.id == id_, + StockKeepingUnit.is_bundle == True, # noqa: E712 + ) + .options(contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product)) + ) + .unique() + .scalar_one_or_none() + ) + + if sv is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") + + pv = sv.sku.product.versions[0] + + # close/delete sku version + if sv.valid_from == date_: + db.delete(sv) + else: + sv.valid_till = date_ - timedelta(days=1) + + # close/delete product version + if pv.valid_from == date_: + db.delete(pv) + else: + pv.valid_till = date_ - timedelta(days=1) + + items = ( + db.execute( + select(BundleItemModel).where( + BundleItemModel.bundle_id == id_, + or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 + or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 + ) + ) + .scalars() + .all() + ) + for bi in items: + if bi.valid_from == date_: + db.delete(bi) + else: + bi.valid_till = date_ - timedelta(days=1) + + db.commit() diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index 1da2429..e458175 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -29,7 +29,7 @@ from ..schemas.product_query import ProductQuery from ..schemas.sale_category import SaleCategoryLink from ..schemas.tax import TaxLink from ..schemas.user_token import UserToken -from . import effective_date +from . import _pv_onclause, _sv_onclause, effective_date router = APIRouter() @@ -168,22 +168,8 @@ def update_route( ) -> None: try: with SessionFuture() as db: - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) + product_version_onclause = _pv_onclause(date_) + sku_version_onclause = _sv_onclause(date_) version: ProductVersion = db.execute( select(ProductVersion) .join(Product, onclause=product_version_onclause) @@ -446,46 +432,35 @@ def product_list(date_: date, db: Session) -> list[schemas.Product]: # ] -@router.get("/query") +@router.get("/query", response_model=list[ProductQuery]) def show_term( + q: str | None = None, mc: uuid.UUID | None = None, sc: uuid.UUID | None = None, date_: date = Depends(effective_date), current_user: UserToken = Depends(get_user), -): - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= date_, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= date_, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= date_), # noqa: E711 - ) +) -> list[ProductQuery]: + product_version_onclause = _pv_onclause(date_) + sku_version_onclause = _sv_onclause(date_) print(f"Fetching products for MenuCategory: {mc}, SaleCategory: {sc}, Date: {date_}") list_: list[ProductQuery] = [] query = ( select(SkuVersion) + .join(SkuVersion.menu_category) .join(StockKeepingUnit, onclause=sku_version_onclause) .join(StockKeepingUnit.product) .join(ProductVersion, onclause=product_version_onclause) - # .join(SkuVersion.menu_category) - # .join(ProductVersion.sale_category) + .join(ProductVersion.sale_category) + .join(SaleCategory.tax) .options( - contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions) + contains_eager(SkuVersion.menu_category), + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category) + .contains_eager(SaleCategory.tax), ) ) - if mc is not None: - query = query.join(SkuVersion.menu_category) - if sc is not None: - query = query.join(SkuVersion.menu_category).join(ProductVersion.sale_category).join(SaleCategory.tax) if mc is not None: query = query.where(SkuVersion.menu_category_id == mc).order_by( Product.sort_order, ProductVersion.name, StockKeepingUnit.sort_order, SkuVersion.units @@ -498,36 +473,15 @@ def show_term( StockKeepingUnit.sort_order, SkuVersion.units, ) - - if mc is not None: - query = query.options( - contains_eager(SkuVersion.menu_category), - ) - - if sc is not None: - query = query.options( - contains_eager(ProductVersion.sale_category).contains_eager(SaleCategory.tax), - ) + if q is not None: + for name in q.split(): + query = query.where(or_(ProductVersion.name.ilike(f"%{name}%"), SkuVersion.units.ilike(f"%{name}%"))) with SessionFuture() as db: for item in db.execute(query).unique().scalars().all(): - if sc is not None: - list_.append( - ProductQuery( - id_=item.sku_id, - name=f"{item.sku.product.versions[0].name} ({item.units})", - menu_category=MenuCategoryLink( - id_=item.menu_category_id, name=item.menu_category.name, skus=[] - ), - price=item.sale_price, - has_happy_hour=item.has_happy_hour, - is_not_available=item.sku.is_not_available, - ) - ) - if mc is not None: - list_.append(query_product_info(item, False)) - if item.has_happy_hour: - list_.append(query_product_info(item, True)) + list_.append(query_product_info(item, False)) + if mc is not None and item.has_happy_hour: + list_.append(query_product_info(item, True)) return list_ diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py new file mode 100644 index 0000000..6d81e4c --- /dev/null +++ b/barker/barker/schemas/bundle.py @@ -0,0 +1,55 @@ +import uuid + +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from . import Daf, to_camel +from .menu_category import MenuCategoryLink + + +class BundleItem(BaseModel): + id_: uuid.UUID | None = None + name: str = Field(..., min_length=1) + item_id: uuid.UUID = Field(..., description="StockKeepingUnit ID of the item") + sale_price: Daf = Field(ge=Decimal(0)) + quantity: Daf = Field(gt=Decimal(0)) + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class BundleIn(BaseModel): + name: str = Field(..., min_length=1) + units: str = Field(..., min_length=1) + sale_price: Daf = Field(ge=Decimal(0)) + has_happy_hour: bool + is_not_available: bool + sort_order: int + + menu_category: MenuCategoryLink = Field(...) + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class Bundle(BundleIn): + id_: uuid.UUID # Sku ID + version_id: uuid.UUID # SkuVersion ID + + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + +class BundleBlank(BaseModel): + name: str + units: str + sale_price: Daf + has_happy_hour: bool + is_not_available: bool + sort_order: int + + menu_category: MenuCategoryLink | None = None + items: list[BundleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) 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/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.css new file mode 100644 index 0000000..e69de29 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..54943ae --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html @@ -0,0 +1,32 @@ +

Edit Bundle Item

+ +
+
+ + Item + + + @for (p of products$ | async; track p) { + {{ p.name }} + } + + + +
+ + Quantity + + + + + Sale Price + + +
+
+
+ +
+ + +
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..a185570 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts @@ -0,0 +1,110 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { debounceTime, distinctUntilChanged, Observable, of as observableOf, switchMap } from 'rxjs'; + +import { ProductQuery } from '../../core/product-query'; +import { ProductService } from '../../product/product.service'; +import { BundleItem } from '../bundle'; + +@Component({ + selector: 'app-bundle-detail-dialog', + templateUrl: './bundle-detail-dialog.component.html', + styleUrls: ['./bundle-detail-dialog.component.css'], + standalone: true, + imports: [ + AsyncPipe, + MatAutocompleteModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + ], +}) +export class BundleDetailDialogComponent implements OnInit { + private readonly dialogRef = inject>(MatDialogRef); + 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; + }>; + + constructor() { + this.form = new FormGroup({ + product: new FormControl('', { nonNullable: true }), + quantity: new FormControl(1, { nonNullable: true }), + salePrice: new FormControl(0, { nonNullable: true }), + }); + + this.products$ = this.form.controls.product.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => { + if (typeof x !== 'string') return observableOf([]); + return x.trim() === '' ? observableOf([]) : this.productSer.query(x); + }), + ); + + // typing after selecting invalidates prior selection + this.form.controls.product.valueChanges.subscribe((x) => { + if (typeof x === 'string') this.selectedProduct = null; + }); + } + + ngOnInit(): void { + // preload display text using existing name + this.form.setValue({ + product: this.data.item.name ?? '', + quantity: Number(this.data.item.quantity ?? 1), + salePrice: Number(this.data.item.salePrice ?? 0), + }); + + this.selectedProduct = null; + } + + displayFn(product?: ProductQuery | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + productSelected(event: MatAutocompleteSelectedEvent): void { + const p = event.option.value as ProductQuery; + this.selectedProduct = p; + } + + accept(): void { + const v = this.form.value; + + const quantity = Number(v.quantity ?? 0); + if (Number.isNaN(quantity) || quantity <= 0) return; + + const salePrice = Number(v.salePrice ?? 0); + if (Number.isNaN(salePrice) || salePrice < 0) return; + + // If a new product was selected, update itemId + name + if (this.selectedProduct?.id) { + this.data.item.itemId = this.selectedProduct.id; + this.data.item.name = this.selectedProduct.name ?? ''; + } else { + // If no selection, don't allow changing SKU silently + // but still allow quantity/price edit + } + + this.data.item.quantity = quantity; + this.data.item.salePrice = salePrice; + + this.dialogRef.close(this.data.item); + } +} 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..98fbb18 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html @@ -0,0 +1,120 @@ +

Bundle

+ +
+
+ + Name + + + + + Units + + +
+ +
+ + Sale Price + + Computed from bundle items + + + + Menu Category + + @for (mc of menuCategories; track mc) { + + {{ mc.name }} + + } + + + + Has Happy Hour? + Not Available? +
+ +

Items

+ +
+ + Product + + + @for (p of itemProducts | async; track p) { + {{ p.name }} + } + + + + + Quantity + + + + + Sale Price + + + + +
+
+ +
+ + + + Item + + {{ row.name }} + + + + + + Qty + + {{ row.quantity }} + + + + + + Sale Price + + {{ row.salePrice | currency: 'INR' }} + + + + + + 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..4d58a35 --- /dev/null +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts @@ -0,0 +1,325 @@ +import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatOptionModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged, Observable, switchMap } from 'rxjs'; + +import { MenuCategory } from '../../core/menu-category'; +import { ProductQuery } from '../../core/product-query'; +import { ProductService } from '../../product/product.service'; +import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; +import { Bundle, BundleItem } from '../bundle'; +import { BundleService } from '../bundle.service'; +import { BundleDetailDatasource } from './bundle-detail-datasource'; +import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; + +@Component({ + selector: 'app-bundle-detail', + templateUrl: './bundle-detail.component.html', + styleUrls: ['./bundle-detail.component.css'], + imports: [ + AsyncPipe, + CurrencyPipe, + MatAutocompleteModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + ], +}) +export class BundleDetailComponent implements OnInit, AfterViewInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + private ser = inject(BundleService); + private productSer = inject(ProductService); + + @ViewChild('name', { static: true }) nameElement?: ElementRef; + + form: FormGroup<{ + // Bundle header fields + name: FormControl; + units: FormControl; + salePrice: FormControl; + menuCategory: FormControl; + hasHappyHour: FormControl; + isNotAvailable: FormControl; + + // Item add row + addRow: FormGroup<{ + itemId: FormControl; + quantity: FormControl; + salePrice: FormControl; + }>; + }>; + + menuCategories: MenuCategory[] = []; + public items$ = new BehaviorSubject([]); + dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$); + + item: Bundle = new Bundle(); + + itemProduct: ProductQuery | null = null; + itemProducts: Observable; + + displayedColumns = ['name', 'quantity', 'salePrice', '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 }), + 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 }), + }), + }); + this.itemProducts = this.form.controls.addRow.controls.itemId.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => { + // if user types, x is string; if user selects, x is Product + if (typeof x !== 'string') { + return observableOf([]); + } + return x.trim() === '' ? observableOf([]) : this.productSer.query(x); + }), + ); + this.form.controls.addRow.controls.itemId.valueChanges.subscribe((x) => { + if (typeof x === 'string') { + this.itemProduct = null; + } + }); + } + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { + item: Bundle; + menuCategories: MenuCategory[]; + }; + this.menuCategories = data.menuCategories; + this.showItem(data.item); + }); + } + + displayFn(product?: ProductQuery | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + bundleSelected(event: MatAutocompleteSelectedEvent): void { + const product = event.option.value as ProductQuery; + this.itemProduct = product; + this.form.controls.addRow.controls.salePrice.setValue(product.price ?? 0); + } + + private recalcBundleSalePrice(): void { + const total = (this.item.items ?? []).reduce((sum, x) => { + const qty = Number(x.quantity ?? 0); + const price = Number(x.salePrice ?? 0); + if (Number.isNaN(qty) || Number.isNaN(price)) return sum; + return sum + qty * price; + }, 0); + + // keep model + form in sync + this.item.salePrice = Number(total.toFixed(2)); + + // if the control is disabled, use patchValue + this.form.controls.salePrice.patchValue(this.item.salePrice); + } + + showItem(item: Bundle) { + this.item = item; + + this.form.setValue({ + name: this.item.name ?? '', + units: this.item.units ?? '', + salePrice: Number(this.item.salePrice ?? 0), + menuCategory: this.item.menuCategory?.id ?? '', + hasHappyHour: this.item.hasHappyHour ?? false, + isNotAvailable: this.item.isNotAvailable ?? false, + + addRow: { + itemId: '', + quantity: 1, + salePrice: 0, + }, + }); + this.itemProduct = null; + this.form.controls.salePrice.disable({ emitEvent: false }); + + this.items$.next(this.item.items ?? []); + this.recalcBundleSalePrice(); + } + + ngAfterViewInit() { + setTimeout(() => { + if (this.nameElement !== undefined) { + this.nameElement.nativeElement.focus(); + } + }, 0); + } + + resetAddRow() { + this.form.controls.addRow.reset(); + this.itemProduct = null; + } + + addRow() { + const v = this.form.value.addRow; + if (!v) { + return; + } + + if (!this.itemProduct?.id) { + this.snackBar.open('Please select a product', 'Error'); + return; + } + + const quantity = Number(v.quantity ?? 0); + if (Number.isNaN(quantity) || quantity <= 0) { + this.snackBar.open('Quantity has to be > 0', 'Error'); + return; + } + + const salePrice = Number(v.salePrice ?? 0); + if (Number.isNaN(salePrice) || salePrice < 0) { + this.snackBar.open('Sale Price has to be >= 0', 'Error'); + return; + } + + // name is expected from backend as "name (units)" on GET; + // for new rows we keep it blank (or you can set placeholder) + const bi = new BundleItem({ + itemId: this.itemProduct?.id, + name: this.itemProduct?.name ?? '', + quantity, + salePrice, + }); + + this.item.items.push(bi); + this.items$.next(this.item.items); + this.resetAddRow(); + + this.recalcBundleSalePrice(); + } + + editRow(row: BundleItem) { + const dialogRef = this.dialog.open(BundleDetailDialogComponent, { + width: '650px', + data: { + item: JSON.parse(JSON.stringify(row)) as BundleItem, + }, + }); + + dialogRef.afterClosed().subscribe((result: boolean | BundleItem) => { + if (!result) { + return; + } + Object.assign(row, result as BundleItem); + this.items$.next(this.item.items); + this.resetAddRow(); + this.recalcBundleSalePrice(); + }); + } + + deleteRow(row: BundleItem) { + this.item.items.splice(this.item.items.indexOf(row), 1); + this.items$.next(this.item.items); + this.recalcBundleSalePrice(); + } + + save() { + if (!this.item.items || this.item.items.length === 0) { + this.snackBar.open('Bundle must contain at least one item', 'Error'); + return; + } + this.ser.saveOrUpdate(this.getItem()).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + this.router.navigateByUrl('/bundles'); + }, + error: (error) => { + this.snackBar.open(error, 'Error'); + }, + }); + } + + delete() { + this.ser.delete(this.item.id as string).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + this.router.navigateByUrl('/bundles'); + }, + error: (error) => { + this.snackBar.open(error, 'Error'); + }, + }); + } + + confirmDelete(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '250px', + data: { title: 'Delete Bundle?', content: 'Are you sure? This cannot be undone.' }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.delete(); + } + }); + } + + getItem(): Bundle { + const v = this.form.value; + + this.item.name = v.name ?? ''; + this.item.units = v.units ?? ''; + this.item.salePrice = Number(v.salePrice ?? 0); + this.item.hasHappyHour = v.hasHappyHour ?? false; + this.item.isNotAvailable = v.isNotAvailable ?? false; + this.item.sortOrder = this.item.sortOrder ?? 0; + + // menu category + const menuCategoryId = v.menuCategory ?? ''; + if (!menuCategoryId) { + // keep it as-is; backend will 422 anyway, but we can show UI error too + this.snackBar.open('Menu Category is required', 'Error'); + return this.item; + } + + if (this.item.menuCategory === null || this.item.menuCategory === undefined) { + this.item.menuCategory = new MenuCategory(); + } + this.item.menuCategory.id = menuCategoryId; + + // ensure items array exists + if (!this.item.items) { + this.item.items = []; + } + + return this.item; + } +} 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..e7fd27e --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.html @@ -0,0 +1,94 @@ +

+ 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 }} + + + + + + Items + +
+ @for (item of row.items; track item) { +
{{ 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..17cd7c9 --- /dev/null +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts @@ -0,0 +1,117 @@ +import { AsyncPipe, CurrencyPipe } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatOptionModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { MenuCategory } from '../../core/menu-category'; +import { ToCsvService } from '../../shared/to-csv.service'; +import { Bundle } from '../bundle'; +import { BundleListDataSource } from './bundle-list-datasource'; + +@Component({ + selector: 'app-bundle-list', + templateUrl: './bundle-list.component.html', + styleUrls: ['./bundle-list.component.css'], + imports: [ + AsyncPipe, + CurrencyPipe, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatOptionModule, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class BundleListComponent implements OnInit { + private route = inject(ActivatedRoute); + private toCsv = inject(ToCsvService); + + searchFilter = new BehaviorSubject(''); + 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', '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', + 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..380c531 --- /dev/null +++ b/bookie/src/app/bundles/bundle.ts @@ -0,0 +1,48 @@ +import { MenuCategory } from '../core/menu-category'; + +export class BundleItem { + id?: string; + itemId: string; + name: string; + salePrice: number; + quantity: number; + + public constructor(init?: Partial) { + this.itemId = ''; + this.name = ''; + this.salePrice = 0; + this.quantity = 0; + Object.assign(this, init); + } +} + +export class Bundle { + // backend: id_ (sku id), version_id (skuVersion id) + id?: string; + versionId?: string; + + name: string; + units: string; + salePrice: number; + + hasHappyHour: boolean; + isNotAvailable: boolean; + sortOrder: number; + + menuCategory?: MenuCategory | null; + items: BundleItem[]; + + public constructor(init?: Partial) { + 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..a20c3eb --- /dev/null +++ b/bookie/src/app/bundles/bundles.routes.ts @@ -0,0 +1,41 @@ +import { Routes } from '@angular/router'; + +import { authGuard } from '../auth/auth-guard.service'; +import { menuCategoryListResolver } from '../menu-category/menu-category-list.resolver'; +import { BundleDetailComponent } from './bundle-detail/bundle-detail.component'; +import { bundleListResolver } from './bundle-list.resolver'; +import { BundleListComponent } from './bundle-list/bundle-list.component'; +import { bundleResolver } from './bundle.resolver'; + +export const routes: Routes = [ + { + path: '', + component: BundleListComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, // change if you have a separate permission + resolve: { + list: bundleListResolver, + menuCategories: menuCategoryListResolver, + }, + }, + { + path: 'new', + component: BundleDetailComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, + resolve: { + item: bundleResolver, // returns new Bundle() because id === null (no param here) + menuCategories: menuCategoryListResolver, + }, + }, + { + path: ':id', + component: BundleDetailComponent, + canActivate: [authGuard], + data: { permission: 'Products' }, + resolve: { + item: bundleResolver, + menuCategories: menuCategoryListResolver, + }, + }, +]; diff --git a/bookie/src/app/core/stock-keeping-unit.ts b/bookie/src/app/core/product-query.ts similarity index 58% rename from bookie/src/app/core/stock-keeping-unit.ts rename to bookie/src/app/core/product-query.ts index 09276e0..dd65bd1 100644 --- a/bookie/src/app/core/stock-keeping-unit.ts +++ b/bookie/src/app/core/product-query.ts @@ -2,39 +2,25 @@ import { MenuCategory } from './menu-category'; import { SaleCategory } from './sale-category'; import { Tax } from './tax'; -export class StockKeepingUnit { +export class ProductQuery { id: string | undefined; - versionId?: string; name: string; - units: string; - menuCategory?: MenuCategory; - saleCategory?: SaleCategory; price: number; hasHappyHour: boolean; isNotAvailable: boolean; - quantity: number; - isActive: boolean; sortOrder: number; + menuCategory?: MenuCategory; + saleCategory?: SaleCategory; - enabled: boolean; tax: Tax; - validFrom: string | null; - validTill: string | null; - - public constructor(init?: Partial) { + public constructor(init?: Partial) { this.id = undefined; this.name = ''; - this.units = ''; this.price = 0; this.hasHappyHour = false; this.isNotAvailable = false; - this.quantity = 0; - this.isActive = true; this.sortOrder = 0; - this.enabled = true; - this.validFrom = null; - this.validTill = null; this.tax = new Tax(); Object.assign(this, init); } 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/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..9c728a5 100644 --- a/bookie/src/app/sales/bill.service.ts +++ b/bookie/src/app/sales/bill.service.ts @@ -9,13 +9,13 @@ import { BillViewItem } from '../core/bill-view-item'; import { ModifierCategory } from '../core/modifier-category'; import { ReceivePaymentItem } from '../core/receive-payment-item'; import { SaleCategory } from '../core/sale-category'; -import { StockKeepingUnit } from '../core/stock-keeping-unit'; import { Table } from '../core/table'; import { ModifierCategoryService } from '../modifier-categories/modifier-category.service'; import { MathService } from '../shared/math.service'; import { Bill } from './bills/bill'; import { Inventory } from './bills/inventory'; import { Kot } from './bills/kot'; +import { ProductLink } from './bills/product-link'; import { VoucherType } from './bills/voucher-type'; import { VoucherService } from './bills/voucher.service'; import { ModifiersComponent } from './modifiers/modifiers.component'; @@ -109,7 +109,7 @@ export class BillService { ); } - addSku(sku: StockKeepingUnit, quantity: number, discount: number): void { + addSku(sku: ProductLink, quantity: number, discount: number): void { const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; const old = newKot.inventories.find((x) => x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour); if (quantity < 0) { @@ -127,7 +127,7 @@ export class BillService { quantity, price: sku.price, isHappyHour: sku.hasHappyHour, - taxRate: sku.tax.rate, + taxRate: sku.tax?.rate, tax: sku.tax, discount, modifiers: [], diff --git a/bookie/src/app/sales/bills/inventory.ts b/bookie/src/app/sales/bills/inventory.ts index af8eab8..e5d9cc2 100644 --- a/bookie/src/app/sales/bills/inventory.ts +++ b/bookie/src/app/sales/bills/inventory.ts @@ -1,10 +1,10 @@ import { Modifier } from '../../core/modifier'; -import { StockKeepingUnit as Product } from '../../core/stock-keeping-unit'; import { Tax } from '../../core/tax'; +import { ProductLink } from './product-link'; export class Inventory { id: string | undefined; - sku: Product; + sku: ProductLink; quantity: number; price: number; isHappyHour: boolean; @@ -16,7 +16,7 @@ export class Inventory { public constructor(init?: Partial) { this.id = undefined; - this.sku = new Product(); + this.sku = new ProductLink(); this.quantity = 0; this.price = 0; this.isHappyHour = false; 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/products/products.component.ts b/bookie/src/app/sales/products/products.component.ts index dd28522..4b9838a 100644 --- a/bookie/src/app/sales/products/products.component.ts +++ b/bookie/src/app/sales/products/products.component.ts @@ -4,8 +4,9 @@ import { MatCardModule } from '@angular/material/card'; import { MatRippleModule } from '@angular/material/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ProductQuery } from '../../core/product-query'; import { BillService } from '../bill.service'; -import { Product } from '../product'; +import { ProductLink } from '../bills/product-link'; @Component({ selector: 'app-products', @@ -17,19 +18,19 @@ export class ProductsComponent implements OnInit { private route = inject(ActivatedRoute); private bs = inject(BillService); - list: Product[] = []; + list: ProductQuery[] = []; ngOnInit() { this.route.data.subscribe((value) => { - const data = value as { list: Product[] }; + const data = value as { list: ProductQuery[] }; this.list = data.list; }); } - addSku(product: Product): void { + addSku(product: ProductQuery): void { if (product.isNotAvailable) { return; } - this.bs.addSku(product, 1, 0); + this.bs.addSku(new ProductLink(product), 1, 0); } } 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/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; } } From 8957e4ce8c8dd2aa964b6cb6edc85f45a3b2367d Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 5 Feb 2026 00:46:49 +0000 Subject: [PATCH 3/7] All voucher routes now work with bundles. Need to check / update all reports and prints --- .../alembic/versions/367ecf7b898f_bundles.py | 2 +- barker/barker/models/inventory.py | 35 +- barker/barker/models/voucher.py | 2 +- barker/barker/routers/__init__.py | 24 +- barker/barker/routers/product.py | 98 +++-- barker/barker/routers/voucher/__init__.py | 76 +++- barker/barker/routers/voucher/save.py | 145 ++++--- barker/barker/routers/voucher/show.py | 255 +++++-------- barker/barker/routers/voucher/split.py | 41 +- barker/barker/routers/voucher/update.py | 196 ++++++---- .../barker/schemas/beer_consumption_report.py | 4 +- barker/barker/schemas/bundle.py | 17 +- barker/barker/schemas/customer.py | 11 +- barker/barker/schemas/device.py | 6 +- barker/barker/schemas/guest_book.py | 4 +- barker/barker/schemas/master.py | 4 +- barker/barker/schemas/menu_category.py | 6 +- barker/barker/schemas/modifier.py | 7 +- barker/barker/schemas/modifier_category.py | 10 +- .../schemas/modifier_category_for_product.py | 8 +- barker/barker/schemas/printer.py | 8 +- barker/barker/schemas/product.py | 8 +- barker/barker/schemas/product_link.py | 4 +- barker/barker/schemas/product_query.py | 28 +- barker/barker/schemas/product_sale_report.py | 4 +- barker/barker/schemas/regime.py | 8 +- barker/barker/schemas/role.py | 4 +- barker/barker/schemas/sale_category.py | 9 +- barker/barker/schemas/section.py | 6 +- barker/barker/schemas/section_printer.py | 4 +- barker/barker/schemas/stock_keeping_unit.py | 16 +- barker/barker/schemas/table.py | 8 +- barker/barker/schemas/tax.py | 11 +- barker/barker/schemas/temporal_product.py | 26 +- barker/barker/schemas/voucher.py | 17 +- barker/barker/schemas/voucher_out.py | 19 +- bookie/src/app/core/bill-view-item.ts | 34 -- bookie/src/app/core/product-query.ts | 22 +- bookie/src/app/sales/bill.service.ts | 361 +++++++++--------- bookie/src/app/sales/bills/bill-row.ts | 18 + .../app/sales/bills/bill-selection-item.ts | 14 - .../src/app/sales/bills/bills-datasource.ts | 48 ++- .../src/app/sales/bills/bills.component.html | 136 ++++--- .../src/app/sales/bills/bills.component.sass | 67 ++-- bookie/src/app/sales/bills/bills.component.ts | 118 +++--- bookie/src/app/sales/bills/inventory.ts | 21 +- .../app/sales/home/sales-home.component.ts | 6 +- .../app/sales/products/products.component.ts | 3 +- .../tables-dialog.component.html | 2 +- .../tables-dialog/tables-dialog.component.ts | 19 +- bookie/src/layout.sass | 4 + bookie/src/square-buttons.sass | 4 +- bookie/src/styles.sass | 24 +- 53 files changed, 1133 insertions(+), 899 deletions(-) delete mode 100644 bookie/src/app/core/bill-view-item.ts create mode 100644 bookie/src/app/sales/bills/bill-row.ts delete mode 100644 bookie/src/app/sales/bills/bill-selection-item.ts diff --git a/barker/alembic/versions/367ecf7b898f_bundles.py b/barker/alembic/versions/367ecf7b898f_bundles.py index 58cbe8f..fc76723 100644 --- a/barker/alembic/versions/367ecf7b898f_bundles.py +++ b/barker/alembic/versions/367ecf7b898f_bundles.py @@ -54,7 +54,7 @@ def upgrade(): op.create_unique_constraint( op.f("uq_inventories_kot_id"), "inventories", - ["kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"], + ["kot_id", "sku_id", "is_happy_hour", "type", "parent_id"], postgresql_nulls_not_distinct=True, ) op.create_foreign_key( diff --git a/barker/barker/models/inventory.py b/barker/barker/models/inventory.py index 979ac1b..d5d9e99 100644 --- a/barker/barker/models/inventory.py +++ b/barker/barker/models/inventory.py @@ -22,9 +22,8 @@ from sqlalchemy import ( from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship -from barker.models.inventory_type import InventoryType - from ..db.base_class import reg +from .inventory_type import InventoryType from .tax import Tax @@ -37,7 +36,7 @@ if TYPE_CHECKING: @reg.mapped_as_dataclass(unsafe_hash=True) class Inventory: __tablename__ = "inventories" - __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "price", "type", "parent_id"),) + __table_args__ = (UniqueConstraint("kot_id", "sku_id", "is_happy_hour", "type", "parent_id"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) kot_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("kots.id"), nullable=False, index=True) @@ -64,20 +63,26 @@ class Inventory: def __init__( self, - kot_id: uuid.UUID, - sku_id: uuid.UUID | None, - quantity: Decimal, - price: Decimal, - discount: Decimal, - is_hh: bool, - tax_id: uuid.UUID | None, - tax_rate: Decimal, - sort_order: int, + kot_id: uuid.UUID | None = None, + kot: Kot | None = None, + sku_id: uuid.UUID | None = None, + quantity: Decimal = Decimal(0), + price: Decimal = Decimal(0), + discount: Decimal = Decimal(0), + is_hh: bool = False, + tax_id: uuid.UUID | None = None, + tax_rate: Decimal = Decimal(0), + sort_order: int = 0, type_: InventoryType = InventoryType.regular, + parent_id: uuid.UUID | None = None, + parent: Inventory | None = None, sku: StockKeepingUnit | None = None, tax: Tax | None = None, ): - self.kot_id = kot_id + if kot_id is not None: + self.kot_id = kot_id + if kot is not None: + self.kot = kot if sku_id is not None: self.sku_id = sku_id self.quantity = quantity @@ -89,6 +94,10 @@ class Inventory: self.tax_rate = tax_rate self.sort_order = sort_order self.type_ = type_ + if parent_id is not None: + self.parent_id = parent_id + if parent is not None: + self.parent = parent if sku is not None: self.sku = sku if tax is not None: diff --git a/barker/barker/models/voucher.py b/barker/barker/models/voucher.py index 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/routers/__init__.py b/barker/barker/routers/__init__.py index 26803f9..5c23500 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -1,13 +1,14 @@ from datetime import UTC, date, datetime, timedelta +from typing import Any -from sqlalchemy import and_, or_ - -from barker.models.product import Product -from barker.models.product_version import ProductVersion -from barker.models.sku_version import SkuVersion -from barker.models.stock_keeping_unit import StockKeepingUnit +from sqlalchemy import Label, and_, or_ from ..core.config import settings +from ..models.bundle_item import BundleItem +from ..models.product import Product +from ..models.product_version import ProductVersion +from ..models.sku_version import SkuVersion +from ..models.stock_keeping_unit import StockKeepingUnit def query_date(d: str | None = None) -> date: @@ -55,7 +56,14 @@ def _sv_active(date_: date): ) -def _pv_onclause(date_: date): +def _bundle_active(date_: date): + return and_( + or_(BundleItem.valid_from == None, BundleItem.valid_from <= date_), # noqa: E711 + or_(BundleItem.valid_till == None, BundleItem.valid_till >= date_), # noqa: E711 + ) + + +def _pv_onclause(date_: date | Label[Any]): return and_( ProductVersion.product_id == Product.id, or_( @@ -69,7 +77,7 @@ def _pv_onclause(date_: date): ) -def _sv_onclause(date_: date): +def _sv_onclause(date_: date | Label[Any]): return and_( SkuVersion.sku_id == StockKeepingUnit.id, or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= date_), # noqa: E711 diff --git a/barker/barker/routers/product.py b/barker/barker/routers/product.py index e458175..726a552 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -1,5 +1,6 @@ import uuid +from collections.abc import Sequence from datetime import date, timedelta from decimal import Decimal @@ -12,6 +13,7 @@ from sqlalchemy.sql.functions import count, func from ..core.config import settings from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture +from ..models.bundle_item import BundleItem from ..models.inventory import Inventory from ..models.kot import Kot from ..models.menu_category import MenuCategory @@ -25,7 +27,7 @@ from ..models.stock_keeping_unit import StockKeepingUnit from ..models.voucher import Voucher from ..schemas import product as schemas from ..schemas.menu_category import MenuCategoryLink -from ..schemas.product_query import ProductQuery +from ..schemas.product_query import BundleItemQuery, ProductQuery from ..schemas.sale_category import SaleCategoryLink from ..schemas.tax import TaxLink from ..schemas.user_token import UserToken @@ -432,6 +434,32 @@ def product_list(date_: date, db: Session) -> list[schemas.Product]: # ] +def bundle_items(sku_id: uuid.UUID, date_: date, db: Session) -> Sequence[BundleItem]: + return ( + db.execute( + select(BundleItem) + .join(BundleItem.item) + .join(SkuVersion, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .where( + BundleItem.bundle_id == sku_id, + or_(BundleItem.valid_from == None, BundleItem.valid_from <= date_), # noqa: E711 + or_(BundleItem.valid_till == None, BundleItem.valid_till >= date_), # noqa: E711 + ) + .options( + contains_eager(BundleItem.item).contains_eager(StockKeepingUnit.versions), + contains_eager(BundleItem.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) + ) + .unique() + .scalars() + .all() + ) + + @router.get("/query", response_model=list[ProductQuery]) def show_term( q: str | None = None, @@ -478,46 +506,18 @@ def show_term( query = query.where(or_(ProductVersion.name.ilike(f"%{name}%"), SkuVersion.units.ilike(f"%{name}%"))) with SessionFuture() as db: - for item in db.execute(query).unique().scalars().all(): - list_.append(query_product_info(item, False)) - if mc is not None and item.has_happy_hour: - list_.append(query_product_info(item, True)) + for sv in db.execute(query).unique().scalars().all(): + bi: Sequence[BundleItem] = [] + if sv.sku.is_bundle: + bi = bundle_items(sv.sku_id, date_, db) + info = query_product_info(sv, False, bi) + list_.append(info) + if mc is not None and sv.has_happy_hour: + info = query_product_info(sv, True, bi) + list_.append(info) return list_ -# def product_list_of_sale_category(date_: date, db: Session) -> list[schemas.Product]: -# return [ -# product_info(item) -# for item in db.execute( -# select(ProductVersion) -# .join(ProductVersion.menu_category) -# .join(ProductVersion.sale_category) -# .where( -# and_( -# or_( -# ProductVersion.valid_from == None, # noqa: E711 -# ProductVersion.valid_from <= date_, -# ), -# or_( -# ProductVersion.valid_till == None, # noqa: E711 -# ProductVersion.valid_till >= date_, -# ), -# ) -# ) -# .order_by(MenuCategory.sort_order) -# .order_by(MenuCategory.name) -# .order_by(ProductVersion.sort_order) -# .order_by(ProductVersion.name) -# .options( -# contains_eager(ProductVersion.menu_category), -# contains_eager(ProductVersion.sale_category), -# ) -# ) -# .scalars() -# .all() -# ] - - @router.get("/{id_}", response_model=schemas.Product) def show_id( id_: uuid.UUID, @@ -564,10 +564,15 @@ def show_id( return product_info(version) -def query_product_info(item: SkuVersion, happy_hour: bool) -> ProductQuery: +def query_product_info(item: SkuVersion, happy_hour: bool, bundle_items: Sequence[BundleItem]) -> ProductQuery: return ProductQuery( id_=item.sku_id, name=("H H " if happy_hour else "") + f"{item.sku.product.versions[0].name} ({item.units})", + menu_category=MenuCategoryLink( + id_=item.menu_category.id, + name=item.menu_category.name, + skus=[], + ), sale_category=SaleCategoryLink( id_=item.sku.product.versions[0].sale_category_id, name=item.sku.product.versions[0].sale_category.name, @@ -581,6 +586,21 @@ def query_product_info(item: SkuVersion, happy_hour: bool) -> ProductQuery: has_happy_hour=happy_hour, is_not_available=item.sku.is_not_available, sort_order=item.sku.sort_order, + is_bundle=item.sku.is_bundle, + bundle_items=[ + BundleItemQuery( + id_=bi.item_id, + name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})", + price=bi.sale_price, + quantity=bi.quantity, + tax=TaxLink( + id_=bi.item.product.versions[0].sale_category.tax.id, + name=bi.item.product.versions[0].sale_category.tax.name, + rate=bi.item.product.versions[0].sale_category.tax.rate, + ), + ) + for bi in bundle_items + ], ) diff --git a/barker/barker/routers/voucher/__init__.py b/barker/barker/routers/voucher/__init__.py index b41c5db..7acbab3 100644 --- a/barker/barker/routers/voucher/__init__.py +++ b/barker/barker/routers/voucher/__init__.py @@ -4,18 +4,24 @@ from datetime import datetime from decimal import Decimal from fastapi import HTTPException, status -from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy import func, select +from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql import expression from ...models.bill import Bill from ...models.guest_book import GuestBook from ...models.guest_book_type import GuestBookType +from ...models.inventory import Inventory +from ...models.kot import Kot from ...models.overview import Overview from ...models.overview_status import OverviewStatus +from ...models.product import Product +from ...models.product_version import ProductVersion from ...models.regime import Regime from ...models.settle_option import SettleOption from ...models.settlement import Settlement +from ...models.sku_version import SkuVersion +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas import voucher as schemas @@ -180,3 +186,69 @@ def happy_hour_items_more_than_regular(kots: list[schemas.Kot]) -> bool: else: inventories[inventory.sku.id_]["normal"] += inventory.quantity return any(value["happy"] > value["normal"] for value in inventories.values()) + + +def get_voucher(voucher_id: uuid.UUID, db: Session, product_version_onclause, sku_version_onclause) -> Voucher: + item: Voucher = ( + db.execute( + select(Voucher) + .join(Voucher.food_table) + .join(Voucher.customer, isouter=True) + .join(Voucher.kots) + .join(Kot.inventories) + .join(Inventory.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .where(Voucher.id == voucher_id) + .order_by(Kot.code, Inventory.sort_order) + .options( + contains_eager(Voucher.food_table), + contains_eager(Voucher.customer), + contains_eager(Voucher.kots) + .contains_eager(Kot.inventories) + .contains_eager(Inventory.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + contains_eager(Voucher.kots) + .contains_eager(Kot.inventories) + .contains_eager(Inventory.sku) + .contains_eager(StockKeepingUnit.versions), + ) + ) + .unique() + .scalar_one() + ) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Voucher not found", + ) + return item + + +def get_sku( + sku_id: uuid.UUID, + db: Session, + product_version_onclause, + sku_version_onclause, +) -> StockKeepingUnit: + sku: StockKeepingUnit = ( + db.execute( + select(StockKeepingUnit) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.sale_category) + .where(StockKeepingUnit.id == sku_id) + .options( + contains_eager(StockKeepingUnit.versions), + contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), + ) + ) + .unique() + .scalar_one() + ) + return sku diff --git a/barker/barker/routers/voucher/save.py b/barker/barker/routers/voucher/save.py index 79472ed..8bae387 100644 --- a/barker/barker/routers/voucher/save.py +++ b/barker/barker/routers/voucher/save.py @@ -5,21 +5,19 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import and_, func, or_, select +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, contains_eager +from sqlalchemy.orm import Session from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture +from ...models.bundle_item import BundleItem from ...models.guest_book import GuestBook from ...models.inventory import Inventory from ...models.inventory_modifier import InventoryModifier +from ...models.inventory_type import InventoryType from ...models.kot import Kot -from ...models.product import Product -from ...models.product_version import ProductVersion -from ...models.sku_version import SkuVersion -from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.bill import print_bill @@ -30,12 +28,14 @@ from ...routers.voucher import ( do_update_settlements, do_update_table, get_guest_book, + get_sku, get_tax, happy_hour_has_discount, happy_hour_items_balanced, ) from ...schemas import voucher as schemas from ...schemas.user_token import UserToken +from .. import _bundle_active, _pv_onclause, _sv_onclause router = APIRouter() @@ -82,27 +82,13 @@ def do_save( ) -> Voucher: now = datetime.now(UTC).replace(tzinfo=None) product_date = (now + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES)).date() - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= product_date, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= product_date, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= product_date), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= product_date), # noqa: E711 - ) + product_version_onclause = _pv_onclause(product_date) + sku_version_onclause = _sv_onclause(product_date) check_permissions(None, voucher_type, user.permissions) kot_id = db.execute(select(func.coalesce(func.max(Voucher.kot_id), 0) + 1)).scalar_one() - item = Voucher( + voucher = Voucher( now, guest_book.pax if guest_book is not None else data.pax, kot_id, @@ -111,86 +97,127 @@ def do_save( voucher_type, user.id_, ) - db.add(item) + db.add(voucher) for dk in data.kots: # Filter out nil inventories dk.inventories = [dki for dki in dk.inventories if round(dki.quantity, 2) != 0] # Filter out nil kots data.kots = [k for k in data.kots if len(k.inventories) > 0] - for k in data.kots: - if not happy_hour_items_balanced(k.inventories): + for data_kot in data.kots: + if not happy_hour_items_balanced(data_kot.inventories): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Happy hour products are not balanced.", ) - if happy_hour_has_discount(k.inventories): + if happy_hour_has_discount(data_kot.inventories): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Discount is not allowed on happy hour products.", ) code = db.execute(select(func.coalesce(func.max(Kot.code), 0) + 1)).scalar_one() - kot = Kot(item.id, code, item.food_table_id, item.date, item.user_id) - item.kots.append(kot) + kot = Kot(voucher.id, code, voucher.food_table_id, voucher.date, voucher.user_id) + voucher.kots.append(kot) db.add(kot) - for index, i in enumerate(k.inventories): - if round(i.quantity, 2) == 0: + index = 0 + for data_inv in data_kot.inventories: + index += 1 + if round(data_inv.quantity, 2) == 0: continue total_quantity: Decimal = round( - Decimal(sum(inv.quantity for ko in data.kots for inv in ko.inventories if inv.sku.id_ == i.sku.id_)), + Decimal( + sum(inv.quantity for ko in data.kots for inv in ko.inventories if inv.sku.id_ == data_inv.sku.id_) + ), 2, ) - sku: StockKeepingUnit = ( - db.execute( - select(StockKeepingUnit) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .join(ProductVersion.sale_category) - .where(StockKeepingUnit.id == i.sku.id_) - .options( - contains_eager(StockKeepingUnit.versions), - contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions) - .contains_eager(ProductVersion.sale_category), - ) - ) - .unique() - .scalar_one() - ) + sku = get_sku(data_inv.sku.id_, db, product_version_onclause, sku_version_onclause) if total_quantity < 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Quantity of {sku.product.versions[0].name} ({sku.versions[0].units}) cannot be less than 0", ) - if round(i.quantity, 2) < 0 and "edit-printed-product" not in user.permissions: + if round(data_inv.quantity, 2) < 0 and "edit-printed-product" not in user.permissions: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not allowed to delete printed products.\n In this case {sku.product.versions[0].name} ({sku.versions[0].units})", ) + if data_inv.type_ == InventoryType.bundle_item: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items cannot be added directly. Please add the bundle parent item.", + ) + if data_inv.type_ == InventoryType.bundle and len(data_inv.children) == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items must have children items.", + ) tax_rate = get_tax(sku.product.versions[0].sale_category.tax.rate, voucher_type) inv = Inventory( kot_id=kot.id, sku_id=sku.id, - quantity=round(i.quantity, 2), + quantity=round(data_inv.quantity, 2), price=sku.versions[0].sale_price, - discount=round(min(i.discount, sku.product.versions[0].sale_category.discount_limit), 5), - is_hh=i.is_happy_hour, + discount=round(min(data_inv.discount, sku.product.versions[0].sale_category.discount_limit), 5), + is_hh=data_inv.is_happy_hour, tax_id=sku.product.versions[0].sale_category.tax_id, tax_rate=tax_rate, sort_order=index, + type_=data_inv.type_, ) kot.inventories.append(inv) db.add(inv) - for m in i.modifiers: + for m in data_inv.modifiers: mod = InventoryModifier(None, m.id_, Decimal(0)) inv.modifiers.append(mod) db.add(mod) + if data_inv.type_ == InventoryType.bundle: + if data_inv.price != sum(child.price * child.quantity for child in data_inv.children): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle price for {data_inv.sku.name} must be equal to sum of child item prices.", + ) + for child in data_inv.children: + index += 1 + sku_child = get_sku(child.sku.id_, db, product_version_onclause, sku_version_onclause) + tax_rate_child = get_tax(sku_child.product.versions[0].sale_category.tax.rate, voucher_type) + bundle = db.execute( + select(BundleItem).where( + BundleItem.bundle_id == data_inv.sku.id_, + BundleItem.item_id == child.sku.id_, + _bundle_active(product_date), + ) + ).scalar_one_or_none() + if not bundle: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle item {child.sku.name} is not part of bundle {data_inv.sku.name}", + ) + inv_child = Inventory( + kot=kot, + sku_id=sku_child.id, + quantity=round(data_inv.quantity * bundle.quantity, 2), + price=bundle.sale_price, # TODO: Fix + discount=round( + min(data_inv.discount, sku_child.product.versions[0].sale_category.discount_limit), 5 + ), + is_hh=data_inv.is_happy_hour, + tax_id=sku_child.product.versions[0].sale_category.tax_id, + tax_rate=tax_rate_child, + sort_order=index, + type_=InventoryType.bundle_item, + parent=inv, + ) + inv.children.append(inv_child) + db.add(inv_child) + for m in child.modifiers: + mod = InventoryModifier(None, m.id_, Decimal(0)) + inv_child.modifiers.append(mod) + db.add(mod) db.flush() - do_update_settlements(item, [], db) - do_update_bill_numbers(item, db) + do_update_settlements(voucher, [], db) + do_update_bill_numbers(voucher, db) if ( - sum(len(k.inventories) for k in item.kots) == 0 + sum(len(k.inventories) for k in voucher.kots) == 0 and guest_book is None and data.pax == 0 and data.customer is None @@ -199,4 +226,4 @@ def do_save( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Please add some products", ) - return item + return voucher diff --git a/barker/barker/routers/voucher/show.py b/barker/barker/routers/voucher/show.py index 93976e0..2095f61 100644 --- a/barker/barker/routers/voucher/show.py +++ b/barker/barker/routers/voucher/show.py @@ -4,9 +4,11 @@ import uuid from datetime import timedelta from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import Date, and_, func, or_, select +from sqlalchemy import Date, func, select from sqlalchemy.orm import Session, contains_eager +from barker.models.inventory_type import InventoryType + from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture @@ -42,6 +44,8 @@ from ...schemas.voucher_out import ( from ...schemas.voucher_out import ( Kot as KotOut, ) +from .. import _pv_onclause, _sv_onclause +from . import get_voucher router = APIRouter() @@ -49,59 +53,16 @@ router = APIRouter() @router.get("/from-id/{id_}") def from_id( - id_: str, + id_: uuid.UUID, user: UserToken = Security(get_user), ) -> VoucherOut: with SessionFuture() as db: day = func.cast( - Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) - item: Voucher = ( - db.execute( - select(Voucher) - .join(Voucher.food_table) - .join(Voucher.customer, isouter=True) - .join(Voucher.kots) - .join(Kot.inventories) - .join(Inventory.sku) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .where(Voucher.id == id_) - .order_by(Kot.code) - .options( - contains_eager(Voucher.food_table), - contains_eager(Voucher.customer), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.versions), - ) - ) - .unique() - .scalar_one() - ) + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) + item = get_voucher(id_, db, product_version_onclause, sku_version_onclause) return voucher_info(item, db) @@ -112,24 +73,10 @@ def from_bill( ) -> VoucherOut: with SessionFuture() as db: day = func.cast( - Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) match = re.compile(r"^(\w+)-(\d+)$").match(id_) if not match or len(match.groups()) != 2: raise HTTPException( @@ -185,24 +132,10 @@ def from_table( ) -> VoucherOut | VoucherBlank: with SessionFuture() as db: day = func.cast( - Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date ).label("day") - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= day, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= day, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= day), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= day), # noqa: E711 - ) + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) guest = None if g is None else db.execute(select(GuestBook).where(GuestBook.id == g)).scalar_one() if v is not None: overview = ( @@ -221,47 +154,22 @@ def from_table( detail="Voucher not found", ) else: - item = ( - db.execute( - select(Voucher) - .join(Voucher.food_table) - .join(Voucher.customer, isouter=True) - .join(Voucher.kots) - .join(Kot.inventories) - .join(Inventory.sku) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .where(Voucher.id == v) - .order_by(Kot.code) - .options( - contains_eager(Voucher.food_table), - contains_eager(Voucher.customer), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), - contains_eager(Voucher.kots) - .contains_eager(Kot.inventories) - .contains_eager(Inventory.sku) - .contains_eager(StockKeepingUnit.versions), - ) - ) - .unique() - .scalar_one_or_none() - ) + item = get_voucher(v, db, product_version_onclause, sku_version_onclause) if item is None: - item = db.execute( - select(Voucher) - .join(Voucher.food_table) - .join(Voucher.customer, isouter=True) - .where(Voucher.id == v) - .options( - contains_eager(Voucher.food_table), - contains_eager(Voucher.customer), + item = ( + db.execute( + select(Voucher) + .join(Voucher.food_table) + .join(Voucher.customer, isouter=True) + .where(Voucher.id == v) + .options( + contains_eager(Voucher.food_table), + contains_eager(Voucher.customer), + ) ) - ).scalar_one() + .unique() + .scalar_one() + ) if guest is not None: item.customer = guest.customer @@ -271,7 +179,7 @@ def from_table( def voucher_info(item: Voucher, db: Session) -> VoucherOut: - return VoucherOut( + voucher_out = VoucherOut( id_=item.id, date_=item.date, date_tip=item.date, @@ -288,51 +196,64 @@ def voucher_info(item: Voucher, db: Session) -> VoucherOut: narration=item.narration, reason=item.reason, voucher_type=item.voucher_type, - kots=[ - KotOut( - id_=k.id, - code=k.code, - date_=k.date, - user=UserLink(id_=k.user_id, name=k.user.name), - inventories=[ - InventoryOut( - id_=i.id, - sort_order=i.sort_order, - product=InventoryProduct( - id_=i.sku_id, - name=("H H " if i.is_happy_hour else "") - + f"{i.sku.product.versions[0].name} ({i.sku.versions[0].units})", - menu_category=ProductMenuCategory( - id_=i.sku.versions[0].menu_category_id, - name=i.sku.versions[0].menu_category.name, - ), - sale_category=ProductSaleCategory( - id_=i.sku.product.versions[0].sale_category_id, - name=i.sku.product.versions[0].sale_category.name, - discount_limit=i.sku.product.versions[0].sale_category.discount_limit, - ), - ), - quantity=i.quantity, - price=i.price, - is_happy_hour=i.is_happy_hour, - tax_rate=i.tax_rate, - tax=TaxLink(id_=i.tax_id, name=i.tax.name), - discount=i.discount, - modifiers=[ - ModifierLink( - id_=m.modifier.id, - name=m.modifier.name, - price=m.price, - ) - for m in i.modifiers - ], - ) - for i in k.inventories - ], - ) - for k in item.kots - ], + kots=[], ) + for k in item.kots: + kot = KotOut( + id_=k.id, + code=k.code, + date_=k.date, + user=UserLink(id_=k.user_id, name=k.user.name), + inventories=[], + ) + voucher_out.kots.append(kot) + for i in k.inventories: + inventory = InventoryOut( + id_=i.id, + sort_order=i.sort_order, + sku=InventoryProduct( + id_=i.sku_id, + name=("H H " if i.is_happy_hour else "") + + f"{i.sku.product.versions[0].name} ({i.sku.versions[0].units})", + menu_category=ProductMenuCategory( + id_=i.sku.versions[0].menu_category_id, + name=i.sku.versions[0].menu_category.name, + ), + sale_category=ProductSaleCategory( + id_=i.sku.product.versions[0].sale_category_id, + name=i.sku.product.versions[0].sale_category.name, + discount_limit=i.sku.product.versions[0].sale_category.discount_limit, + ), + is_bundle=i.sku.is_bundle, + ), + quantity=i.quantity, + price=i.price, + is_happy_hour=i.is_happy_hour, + tax_rate=i.tax_rate, + tax=TaxLink(id_=i.tax_id, name=i.tax.name), + discount=i.discount, + type_=i.type_, + modifiers=[ + ModifierLink( + id_=m.modifier.id, + name=m.modifier.name, + price=m.price, + ) + for m in i.modifiers + ], + children=[], + ) + if i.parent_id and inventory.type_ != InventoryType.bundle_item: + raise Exception("Inventory type and parent ID mismatch") + if i.parent_id is not None and inventory.type_ == InventoryType.bundle_item: + parent = next((inv for inv in kot.inventories if inv.id_ == i.parent_id), None) + if parent is not None: + parent.children.append(inventory) + else: + raise Exception("Parent inventory not found") + else: + kot.inventories.append(inventory) + return voucher_out def voucher_blank(table: FoodTable, guest: GuestBook | None) -> VoucherBlank: diff --git a/barker/barker/routers/voucher/split.py b/barker/barker/routers/voucher/split.py index 9dc0f4e..806a045 100644 --- a/barker/barker/routers/voucher/split.py +++ b/barker/barker/routers/voucher/split.py @@ -1,18 +1,22 @@ import uuid from collections import defaultdict -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import delete, func, select +from sqlalchemy import Date, delete, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from barker.routers import _pv_onclause, _sv_onclause + +from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture from ...models.inventory import Inventory from ...models.inventory_modifier import InventoryModifier +from ...models.inventory_type import InventoryType from ...models.kot import Kot from ...models.overview import Overview from ...models.settle_option import SettleOption @@ -23,6 +27,7 @@ from ...routers.voucher import ( do_update_bill_numbers, do_update_settlements, do_update_table, + get_voucher, ) from ...schemas import split as schemas from ...schemas.receive_payment import ReceivePaymentItem as SettleSchema @@ -43,7 +48,12 @@ def split( with SessionFuture() as db: now = datetime.now(UTC).replace(tzinfo=None) update_table = u - item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + day = func.cast( + Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date + ).label("day") + product_version_onclause = _pv_onclause(day) + sku_version_onclause = _sv_onclause(day) + item: Voucher = get_voucher(id_, db, product_version_onclause, sku_version_onclause) original_voucher_type = item.voucher_type item.voucher_type = VoucherType.VOID item.reason = "Bill Split" @@ -53,8 +63,18 @@ def split( db.execute(delete(Overview).where(Overview.voucher_id == item.id)) check_permissions(None, item.voucher_type, user.permissions) - one_inventories = [i for k in item.kots for i in k.inventories if i.id in data.inventories] - two_inventories = [i for k in item.kots for i in k.inventories if i.id not in data.inventories] + one_inventories = [ + i + for k in item.kots + for i in k.inventories + if i.id in data.inventories or i.parent_id in data.inventories + ] + two_inventories = [ + i + for k in item.kots + for i in k.inventories + if i.id not in data.inventories and i.parent_id not in data.inventories + ] one = save( one_inventories, @@ -128,7 +148,9 @@ def save( item.kots.append(kot) db.add(kot) db.flush() + parent_map: dict[uuid.UUID, Inventory] = {} for old_inventory in split_inventories: + parent = parent_map.get(old_inventory.parent_id) if old_inventory.parent_id else None inv = Inventory( kot_id=kot.id, sku_id=old_inventory.sku_id, @@ -139,13 +161,20 @@ def save( tax_id=old_inventory.tax_id, tax_rate=old_inventory.tax_rate, sort_order=old_inventory.sort_order, + type_=old_inventory.type_, + parent=parent, ) - kot.inventories.append(inv) + if not parent: + kot.inventories.append(inv) + else: + parent.children.append(inv) db.add(inv) for m in old_inventory.modifiers: mod = InventoryModifier(None, m.modifier_id, m.price) inv.modifiers.append(mod) db.add(mod) + if old_inventory.type_ == InventoryType.bundle: + parent_map[old_inventory.id] = inv db.flush() do_update_bill_numbers(item, db) do_update_settlements(item, [], db) diff --git a/barker/barker/routers/voucher/update.py b/barker/barker/routers/voucher/update.py index 67bd2d7..76d8de9 100644 --- a/barker/barker/routers/voucher/update.py +++ b/barker/barker/routers/voucher/update.py @@ -4,31 +4,34 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import and_, func, or_, select +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import contains_eager +from sqlalchemy.orm import Session, contains_eager from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture +from ...models.bundle_item import BundleItem from ...models.inventory import Inventory from ...models.inventory_modifier import InventoryModifier +from ...models.inventory_type import InventoryType from ...models.kot import Kot from ...models.product import Product from ...models.product_version import ProductVersion -from ...models.sku_version import SkuVersion from ...models.stock_keeping_unit import StockKeepingUnit -from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...printing.bill import print_bill from ...printing.kot import print_kot +from ...routers import _bundle_active, _pv_onclause, _sv_onclause from ...routers.voucher import ( check_permissions, do_update_bill_numbers, do_update_settlements, do_update_table, get_guest_book, + get_sku, get_tax, + get_voucher, happy_hour_has_discount, happy_hour_items_balanced, happy_hour_items_more_than_regular, @@ -40,6 +43,26 @@ from ...schemas.user_token import UserToken router = APIRouter() +def discount_limit(sku_id: uuid.UUID, db: Session, product_version_onclause) -> Decimal: + sku: StockKeepingUnit = ( + db.execute( + select(StockKeepingUnit) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.sale_category) + .where(StockKeepingUnit.id == sku_id) + .options( + contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), + ) + ) + .unique() + .scalar_one() + ) + return sku.product.versions[0].sale_category.discount_limit + + @router.put("/update/{id_}") def update_route( id_: uuid.UUID, @@ -55,65 +78,27 @@ def update_route( product_date = ( now + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES) ).date() - product_version_onclause = and_( - ProductVersion.product_id == Product.id, - or_( - ProductVersion.valid_from == None, # noqa: E711 - ProductVersion.valid_from <= product_date, - ), - or_( - ProductVersion.valid_till == None, # noqa: E711 - ProductVersion.valid_till >= product_date, - ), - ) - sku_version_onclause = and_( - SkuVersion.sku_id == StockKeepingUnit.id, - or_(SkuVersion.valid_from == None, SkuVersion.valid_from <= product_date), # noqa: E711 - or_(SkuVersion.valid_till == None, SkuVersion.valid_till >= product_date), # noqa: E711 - ) + product_version_onclause = _pv_onclause(product_date) + sku_version_onclause = _sv_onclause(product_date) update_table = u voucher_type = VoucherType(p) guest_book = get_guest_book(g, db) need_to_print_kot = False - item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + # item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + item = get_voucher(id_, db, product_version_onclause, sku_version_onclause) check_permissions(item, voucher_type, user.permissions) - - item.pax = data.pax - if data.customer is not None: - item.customer_id = data.customer.id_ - else: - item.customer_id = None - if guest_book is not None: - item.pax = guest_book.pax - item.customer_id = guest_book.customer_id - item.food_table_id = data.table.id_ - if item.voucher_type != VoucherType.KOT: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal error, reprints should not reach here", ) - if item.voucher_type == VoucherType.KOT and voucher_type != VoucherType.KOT: - item.date = now - item.voucher_type = voucher_type - item.user_id = user.id_ - item.last_edit_date = now if happy_hour_items_more_than_regular(data.kots): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="When product has happy hours\n" "Minimum same number of regular items also needed in the whole bill.", ) - for ik in item.kots: - for iki in ik.inventories: - iki.tax_rate = get_tax(iki.tax_rate, voucher_type) - # TODO: Need to check from the database product for the max discount - # However simple relationship does not work as we need the product validity as well - # Still we should not fret too much as we are checking this in the frontend. - iki.discount = next( - round(inv.discount, 5) for ko in data.kots for inv in ko.inventories if inv.id_ == iki.id - ) for dk in data.kots: # Filter out nil inventories dk.inventories = [dki for dki in dk.inventories if round(dki.quantity, 2) != 0] @@ -130,27 +115,48 @@ def update_route( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Happy hour products are not balanced.", ) - for nk in data.kots: - if nk.id_ is not None: + + item.pax = data.pax + if data.customer is not None: + item.customer_id = data.customer.id_ + else: + item.customer_id = None + if guest_book is not None: + item.pax = guest_book.pax + item.customer_id = guest_book.customer_id + item.food_table_id = data.table.id_ + + if item.voucher_type == VoucherType.KOT and voucher_type != VoucherType.KOT: + item.date = now + item.voucher_type = voucher_type + item.user_id = user.id_ + item.last_edit_date = now + flat_inventories: list[schemas.Inventory] = [] + for d_kot in data.kots: + for d_inv in d_kot.inventories: + if d_inv.type_ != InventoryType.bundle_item: + flat_inventories.append(d_inv) + if len(d_inv.children) > 0: + for d_child in d_inv.children: + flat_inventories.append(d_child) + for ik in item.kots: + for iki in ik.inventories: + iki.tax_rate = get_tax(iki.tax_rate, voucher_type) + new_discount = next(round(inv.discount, 5) for inv in flat_inventories if inv.id_ == iki.id) + iki.discount = round(min(new_discount, discount_limit(iki.sku_id, db, product_version_onclause)), 5) + for data_kot in data.kots: + if data_kot.id_ is not None: continue need_to_print_kot = True code = db.execute(select(func.coalesce(func.max(Kot.code), 0) + 1)).scalar_one() kot = Kot(item.id, code, item.food_table_id, now, item.user_id) item.kots.append(kot) db.add(kot) - for index, nki in enumerate(nk.inventories): - sku: StockKeepingUnit = db.execute( - select(StockKeepingUnit) - .join(SkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=product_version_onclause) - .where(StockKeepingUnit.id == nki.sku.id_) - .options( - contains_eager(StockKeepingUnit.versions), - contains_eager(StockKeepingUnit.product).contains_eager(Product.versions), - ) - ).scalar_one() - if round(nki.quantity, 2) < 0: + index = 0 + for data_inv in data_kot.inventories: + index += 1 + sku = get_sku(data_inv.sku.id_, db, product_version_onclause, sku_version_onclause) + if round(data_inv.quantity, 2) < 0: if "edit-printed-product" not in user.permissions: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -169,30 +175,84 @@ def update_route( * -1, 2, ) - if round(nki.quantity, 2) < minimum: + if round(data_inv.quantity, 2) < minimum: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Quantity of {sku.product.versions[0].name} ({sku.versions[0].units}) cannot be less than {minimum}", ) + if data_inv.type_ == InventoryType.bundle_item: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items cannot be added directly. Please add the bundle parent item.", + ) + if data_inv.type_ == InventoryType.bundle and len(data_inv.children) == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Bundle items must have children items.", + ) tax_rate = get_tax(sku.product.versions[0].sale_category.tax.rate, voucher_type) inv = Inventory( - kot_id=kot.id, + kot=kot, sku_id=sku.id, - quantity=round(nki.quantity, 2), + quantity=round(data_inv.quantity, 2), price=sku.versions[0].sale_price, - discount=round(min(nki.discount, sku.product.versions[0].sale_category.discount_limit), 5), - is_hh=nki.is_happy_hour, + discount=round(min(data_inv.discount, sku.product.versions[0].sale_category.discount_limit), 5), + is_hh=data_inv.is_happy_hour, tax_rate=tax_rate, sort_order=index, tax_id=sku.product.versions[0].sale_category.tax_id, - tax=sku.product.versions[0].sale_category.tax, + type_=data_inv.type_, ) kot.inventories.append(inv) db.add(inv) - for m in nki.modifiers: + for m in data_inv.modifiers: mod = InventoryModifier(None, m.id_, Decimal(0)) inv.modifiers.append(mod) db.add(mod) + if data_inv.type_ == InventoryType.bundle: + if data_inv.price != sum(child.price * child.quantity for child in data_inv.children): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle price for {data_inv.sku.name} must be equal to sum of child item prices.", + ) + for child in data_inv.children: + index += 1 + sku_child = get_sku(child.sku.id_, db, product_version_onclause, sku_version_onclause) + tax_rate_child = get_tax(sku_child.product.versions[0].sale_category.tax.rate, voucher_type) + bundle = db.execute( + select(BundleItem).where( + BundleItem.bundle_id == data_inv.sku.id_, + BundleItem.item_id == child.sku.id_, + _bundle_active(product_date), + ) + ).scalar_one_or_none() + if not bundle: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Bundle item {child.sku.name} is not part of bundle {data_inv.sku.name}", + ) + inv_child = Inventory( + kot=kot, + sku_id=sku_child.id, + quantity=round(data_inv.quantity * bundle.quantity, 2), + price=bundle.sale_price, # TODO: Fix + discount=round( + min(data_inv.discount, sku_child.product.versions[0].sale_category.discount_limit), + 5, + ), + is_hh=data_inv.is_happy_hour, + tax_id=sku_child.product.versions[0].sale_category.tax_id, + tax_rate=tax_rate_child, + sort_order=index, + type_=InventoryType.bundle_item, + parent=inv, + ) + inv.children.append(inv_child) + db.add(inv_child) + for m in child.modifiers: + mod = InventoryModifier(None, m.id_, Decimal(0)) + inv_child.modifiers.append(mod) + db.add(mod) do_update_bill_numbers(item, db) do_update_settlements(item, [], db) if update_table: diff --git a/barker/barker/schemas/beer_consumption_report.py b/barker/barker/schemas/beer_consumption_report.py index e37af9f..41c1647 100644 --- a/barker/barker/schemas/beer_consumption_report.py +++ b/barker/barker/schemas/beer_consumption_report.py @@ -1,6 +1,6 @@ from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer @@ -22,7 +22,7 @@ class BeerConsumptionReportItem(BaseModel): return value.strftime("%d-%b-%Y") # dynamic fields (e.g., regular, nc, kot) - dynamic_amounts: dict[str, Daf] = Field(default_factory=dict) + dynamic_amounts: Annotated[dict[str, Daf], Field(default_factory=dict)] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py index 6d81e4c..7d21442 100644 --- a/barker/barker/schemas/bundle.py +++ b/barker/barker/schemas/bundle.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field @@ -10,23 +11,23 @@ from .menu_category import MenuCategoryLink class BundleItem(BaseModel): id_: uuid.UUID | None = None - name: str = Field(..., min_length=1) - item_id: uuid.UUID = Field(..., description="StockKeepingUnit ID of the item") - sale_price: Daf = Field(ge=Decimal(0)) - quantity: Daf = Field(gt=Decimal(0)) + name: Annotated[str, Field(min_length=1)] + item_id: Annotated[uuid.UUID, Field(description="StockKeepingUnit ID of the item")] + sale_price: Annotated[Daf, Field(ge=Decimal(0))] + quantity: Annotated[Daf, Field(gt=Decimal(0))] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) class BundleIn(BaseModel): - name: str = Field(..., min_length=1) - units: str = Field(..., min_length=1) - sale_price: Daf = Field(ge=Decimal(0)) + name: Annotated[str, Field(min_length=1)] + units: Annotated[str, Field(min_length=1)] + sale_price: Annotated[Daf, Field(ge=Decimal(0))] has_happy_hour: bool is_not_available: bool sort_order: int - menu_category: MenuCategoryLink = Field(...) + menu_category: MenuCategoryLink items: list[BundleItem] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/customer.py b/barker/barker/schemas/customer.py index 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/device.py b/barker/barker/schemas/device.py index 1a299ab..da5b082 100644 --- a/barker/barker/schemas/device.py +++ b/barker/barker/schemas/device.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer @@ -10,7 +10,7 @@ from .section import SectionLink class DeviceIn(BaseModel): - name: str = Field(..., min_length=1) + name: Annotated[str, Field(min_length=1)] enabled: bool section: SectionLink model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) @@ -33,6 +33,6 @@ class Device(DeviceIn): class DeviceLink(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/guest_book.py b/barker/barker/schemas/guest_book.py index 7827364..0d66df7 100644 --- a/barker/barker/schemas/guest_book.py +++ b/barker/barker/schemas/guest_book.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Annotated, Any from pydantic import ( BaseModel, @@ -20,7 +20,7 @@ class GuestBookIn(BaseModel): name: str phone: str address: str | None = None - pax: int = Field(ge=0) + pax: Annotated[int, Field(ge=0)] booking_date: datetime | None = None arrival_date: datetime | None = None notes: str | None = None diff --git a/barker/barker/schemas/master.py b/barker/barker/schemas/master.py index 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/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..325d83f 100644 --- a/barker/barker/schemas/product.py +++ b/barker/barker/schemas/product.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator @@ -11,9 +11,9 @@ from .stock_keeping_unit import StockKeepingUnit class ProductIn(BaseModel): - name: str = Field(..., min_length=1) - fraction_units: str = Field(..., min_length=1) - sale_category: SaleCategoryLink = Field(...) + name: Annotated[str, Field(min_length=1)] + fraction_units: Annotated[str, Field(min_length=1)] + sale_category: SaleCategoryLink sort_order: int skus: list[StockKeepingUnit] diff --git a/barker/barker/schemas/product_link.py b/barker/barker/schemas/product_link.py index 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..e13473b 100644 --- a/barker/barker/schemas/product_sale_report.py +++ b/barker/barker/schemas/product_sale_report.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_serializer @@ -16,7 +16,7 @@ class ProductSaleReportItem(BaseModel): is_happy_hour: bool # dynamic fields (e.g., regular, nc, kot) - dynamic_amounts: dict[str, Daf] = Field(default_factory=dict) + dynamic_amounts: Annotated[dict[str, Daf], Field(default_factory=dict)] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/regime.py b/barker/barker/schemas/regime.py index 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/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/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..d3a3099 100644 --- a/barker/barker/schemas/stock_keeping_unit.py +++ b/barker/barker/schemas/stock_keeping_unit.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator @@ -13,13 +13,13 @@ from .menu_category import MenuCategoryLink class StockKeepingUnit(BaseModel): id_: uuid.UUID | None = None version_id: uuid.UUID | None = None - units: str = Field(..., min_length=1) - fraction: Daf = Field(ge=Decimal(1), default=Decimal(1)) - product_yield: Daf = Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1)) - cost_price: Daf = Field(ge=Decimal(0), default=Decimal(0)) - sale_price: Daf = Field(ge=Decimal(0), default=Decimal(0)) - menu_category: MenuCategoryLink = Field(...) - sort_order: int = Field(ge=0, default=0) + units: Annotated[str, Field(min_length=1)] + fraction: Annotated[Daf, Field(ge=Decimal(1), default=Decimal(1))] + product_yield: Annotated[Daf, Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))] + cost_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + sale_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + menu_category: MenuCategoryLink + sort_order: Annotated[int, Field(ge=0, default=0)] has_happy_hour: bool is_not_available: bool diff --git a/barker/barker/schemas/table.py b/barker/barker/schemas/table.py index 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/temporal_product.py b/barker/barker/schemas/temporal_product.py index a8ad9f4..eb801d9 100644 --- a/barker/barker/schemas/temporal_product.py +++ b/barker/barker/schemas/temporal_product.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from decimal import Decimal -from typing import Any +from typing import Annotated, Any from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator @@ -14,9 +14,9 @@ from .sale_category import SaleCategoryLink class Product(BaseModel): id_: uuid.UUID version_id: uuid.UUID - name: str = Field(..., min_length=1) - fraction_units: str = Field(..., min_length=1) - sale_category: SaleCategoryLink = Field(...) + name: Annotated[str, Field(min_length=1)] + fraction_units: Annotated[str, Field(min_length=1)] + sale_category: SaleCategoryLink sort_order: int valid_from: date | None = None @@ -53,13 +53,13 @@ class Product(BaseModel): class StockKeepingUnit(BaseModel): id_: uuid.UUID version_id: uuid.UUID - units: str = Field(..., min_length=1) - fraction: Daf = Field(ge=Decimal(1), default=Decimal(1)) - product_yield: Daf = Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1)) - cost_price: Daf = Field(ge=Decimal(0), default=Decimal(0)) - sale_price: Daf = Field(ge=Decimal(0), default=Decimal(0)) - menu_category: MenuCategoryLink = Field(...) - sort_order: int = Field(ge=0, default=0) + units: Annotated[str, Field(min_length=1)] + fraction: Annotated[Daf, Field(ge=Decimal(1), default=Decimal(1))] + product_yield: Annotated[Daf, Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))] + cost_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + sale_price: Annotated[Daf, Field(ge=Decimal(0), default=Decimal(0))] + menu_category: MenuCategoryLink + sort_order: Annotated[int, Field(ge=0, default=0)] has_happy_hour: bool is_not_available: bool @@ -96,6 +96,6 @@ class StockKeepingUnit(BaseModel): class TemporalProduct(BaseModel): - products: list[Product] = Field(...) - skus: list[StockKeepingUnit] = Field(...) + products: list[Product] + skus: list[StockKeepingUnit] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/barker/barker/schemas/voucher.py b/barker/barker/schemas/voucher.py index 9c31795..0569022 100644 --- a/barker/barker/schemas/voucher.py +++ b/barker/barker/schemas/voucher.py @@ -1,9 +1,12 @@ import uuid from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from barker.models.inventory_type import InventoryType + from . import Daf, to_camel from .customer import CustomerLink from .modifier import ModifierLink @@ -16,12 +19,15 @@ class Inventory(BaseModel): id_: uuid.UUID | None = None sku: ProductLink quantity: Daf - price: Daf | None = None + price: Daf + is_happy_hour: bool + type_: InventoryType + parent_id: uuid.UUID | None = None tax: TaxLink | None = None tax_rate: Daf | None = None - discount: Daf = Field(ge=0, le=1) - is_happy_hour: bool + discount: Annotated[Daf, Field(ge=0, le=1)] modifiers: list[ModifierLink] + children: Annotated[list["Inventory"], Field(default_factory=list)] amount: Daf | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -55,7 +61,7 @@ class Inventory(BaseModel): @model_validator(mode="after") def calculate_amount(self) -> "Inventory": - price = 0 if self.is_happy_hour else self.price + price = Decimal(0) if self.is_happy_hour else (self.price or Decimal(0)) self.amount = round( Decimal(price * self.quantity * (1 - self.discount) * (1 + (self.tax_rate or Decimal(0)))), 2, @@ -63,6 +69,9 @@ class Inventory(BaseModel): return self +Inventory.model_rebuild() + + class Kot(BaseModel): id_: uuid.UUID | None = None inventories: list[Inventory] diff --git a/barker/barker/schemas/voucher_out.py b/barker/barker/schemas/voucher_out.py index 95d74b9..0b02949 100644 --- a/barker/barker/schemas/voucher_out.py +++ b/barker/barker/schemas/voucher_out.py @@ -2,10 +2,12 @@ import uuid from datetime import datetime, timedelta from decimal import Decimal +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator from ..core.config import settings +from ..models.inventory_type import InventoryType from . import Daf, to_camel from .customer import CustomerLink from .modifier import ModifierLink @@ -15,21 +17,22 @@ from .user import UserLink class ProductSaleCategory(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None discount_limit: Daf model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class ProductMenuCategory(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class InventoryProduct(BaseModel): - id_: uuid.UUID = Field(...) + id_: uuid.UUID name: str | None = None + is_bundle: bool menu_category: ProductMenuCategory | None = None sale_category: ProductSaleCategory | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -38,13 +41,16 @@ class InventoryProduct(BaseModel): class Inventory(BaseModel): id_: uuid.UUID | None = None sort_order: int | None = None - product: InventoryProduct + sku: InventoryProduct quantity: Daf price: Daf | None = None tax: TaxLink | None = None tax_rate: Daf | None = None - discount: Daf = Field(ge=0, le=1) + discount: Annotated[Daf, Field(ge=0, le=1)] is_happy_hour: bool + type_: InventoryType + parent_id: uuid.UUID | None = None + children: Annotated[list["Inventory"], Field(default_factory=list)] modifiers: list[ModifierLink] amount: Daf | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -92,6 +98,9 @@ class Inventory(BaseModel): return self +Inventory.model_rebuild() + + class Kot(BaseModel): id_: uuid.UUID | None = None code: int | None = None diff --git a/bookie/src/app/core/bill-view-item.ts b/bookie/src/app/core/bill-view-item.ts deleted file mode 100644 index 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/product-query.ts b/bookie/src/app/core/product-query.ts index dd65bd1..effb740 100644 --- a/bookie/src/app/core/product-query.ts +++ b/bookie/src/app/core/product-query.ts @@ -2,17 +2,35 @@ import { MenuCategory } from './menu-category'; import { SaleCategory } from './sale-category'; import { Tax } from './tax'; +export class BundleItemQuery { + id: string; + name: string; + price: number; + quantity: number; + tax: Tax; + + public constructor(init?: Partial) { + this.id = ''; + this.name = ''; + this.price = 0; + this.quantity = 1; + this.tax = new Tax(); + Object.assign(this, init); + } +} + export class ProductQuery { id: string | undefined; name: string; price: number; hasHappyHour: boolean; isNotAvailable: boolean; + isBundle: boolean; sortOrder: number; menuCategory?: MenuCategory; saleCategory?: SaleCategory; - tax: Tax; + bundleItems: BundleItemQuery[]; public constructor(init?: Partial) { this.id = undefined; @@ -20,8 +38,10 @@ export class ProductQuery { this.price = 0; this.hasHappyHour = false; this.isNotAvailable = false; + this.isBundle = false; this.sortOrder = 0; this.tax = new Tax(); + this.bundleItems = []; Object.assign(this, init); } } diff --git a/bookie/src/app/sales/bill.service.ts b/bookie/src/app/sales/bill.service.ts index 9c728a5..2c78bbb 100644 --- a/bookie/src/app/sales/bill.service.ts +++ b/bookie/src/app/sales/bill.service.ts @@ -3,19 +3,19 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BehaviorSubject, throwError, Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; -import { BillViewItem } from '../core/bill-view-item'; import { ModifierCategory } from '../core/modifier-category'; +import { ProductQuery } from '../core/product-query'; import { ReceivePaymentItem } from '../core/receive-payment-item'; import { SaleCategory } from '../core/sale-category'; import { Table } from '../core/table'; import { ModifierCategoryService } from '../modifier-categories/modifier-category.service'; import { MathService } from '../shared/math.service'; import { Bill } from './bills/bill'; +import { BillRow } from './bills/bill-row'; import { Inventory } from './bills/inventory'; import { Kot } from './bills/kot'; -import { ProductLink } from './bills/product-link'; import { VoucherType } from './bills/voucher-type'; import { VoucherService } from './bills/voucher.service'; import { ModifiersComponent } from './modifiers/modifiers.component'; @@ -30,66 +30,97 @@ export class BillService { private ser = inject(VoucherService); private modifierCategoryService = inject(ModifierCategoryService); - public dataObs: BehaviorSubject; + public dataObs: BehaviorSubject; public bill: Bill = new Bill(); private originalBill: Bill = new Bill(); - public grossAmount: BehaviorSubject; - public discountAmount: BehaviorSubject; - public hhAmount: BehaviorSubject; - public taxAmount: BehaviorSubject; + public grossAmount: Observable; + public discountAmount: Observable; + public hhAmount: Observable; + public taxAmount: Observable; public amount: Observable; public amountVal: number; public selection = new SelectionModel(true, []); - private amountBs: BehaviorSubject; private updateTable: boolean; private allowDeactivate: boolean; // To disable Deactivate Guard on navigation after printing bill or kot. constructor() { - this.dataObs = new BehaviorSubject([]); - this.grossAmount = new BehaviorSubject(0); - this.discountAmount = new BehaviorSubject(0); - this.hhAmount = new BehaviorSubject(0); - this.taxAmount = new BehaviorSubject(0); - this.amountBs = new BehaviorSubject(0); + this.dataObs = new BehaviorSubject([]); this.amountVal = 0; this.updateTable = true; this.allowDeactivate = false; - this.amount = this.amountBs.pipe(tap((x) => (this.amountVal = x))); + + this.grossAmount = this.dataObs.pipe( + map((kots: Kot[]) => + this.math.halfRoundEven( + kots.reduce((t, k) => k.inventories.reduce((a, c) => a + c.price * c.quantity, 0) + t, 0), + ), + ), + ); + + this.hhAmount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? c.price : 0) * c.quantity, 0) + t, + 0, + ), + ); + }), + ); + + this.discountAmount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => + k.inventories.reduce((a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * c.discount, 0) + t, + 0, + ), + ); + }), + ); + + this.taxAmount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => + k.inventories.reduce( + (a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * c.taxRate, + 0, + ) + t, + 0, + ), + ); + }), + ); + + this.amount = this.dataObs.pipe( + map((kots: Kot[]) => { + return this.math.halfRoundEven( + kots.reduce( + (t, k) => + k.inventories.reduce( + (a, c) => + a + + this.math.halfRoundEven( + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate), + 2, + ), + 0, + ) + t, + 0, + ), + ); + }), + ); } displayBill(): void { this.allowDeactivate = false; - const data = this.transformBillToView(this.bill); - this.dataObs.next(data); - this.updateAmounts(); - } - - transformBillToView(bill: Bill): BillViewItem[] { - return bill.kots - .map((k: Kot) => [ - new BillViewItem({ - kotId: k.id, - isKot: true, - info: k.id ? `Kot: ${k.code} / ${k.date} (${k.user.name})` : '== New Kot ==', - }), - ...k.inventories.map( - (i) => - new BillViewItem({ - id: i.id, - kotId: k.id, - isKot: false, - skuId: i.sku.id, - isHappyHour: i.isHappyHour, - isPrinted: !!k.id, - info: `${i.sku.name} @ ${i.price} - ${this.math.halfRoundEven(i.discount * 100, 2)}%`, - quantity: i.quantity, - modifiers: i.modifiers, - }), - ), - ]) - .reduce((a, c) => a.concat(c), []); + this.dataObs.next(this.bill.kots); } loadData(bill: Bill, updateTable: boolean): void { @@ -104,23 +135,29 @@ export class BillService { minimum(skuId: string, happyHour: boolean): number { return this.bill.kots.reduce( (t, k) => - k.inventories.reduce((a, c) => (c.sku.id === skuId && c.isHappyHour === happyHour ? a + c.quantity : a), 0) + t, + k.inventories + .filter((i) => i.sku.id === skuId && i.isHappyHour === happyHour) + .reduce((a, c) => a + c.quantity, 0) + t, 0, ); } - addSku(sku: ProductLink, quantity: number, discount: number): void { + addOneSku(sku: ProductQuery): void { + const quantity = 1; + const discount = 0; const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find((x) => x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour); - if (quantity < 0) { - const minimum = this.minimum(sku.id as string, sku.hasHappyHour) + quantity; - if (minimum + quantity < 0) { - this.snackBar.open('Total quantity cannot be negative!', 'Error'); - return; - } - } + const old = newKot.inventories.find( + (x) => + x.sku.id === sku.id && x.isHappyHour === sku.hasHappyHour && x.type === (sku.isBundle ? 'bundle' : 'regular'), + ); if (old !== undefined) { old.quantity += quantity; + if (old.children) { + old.children.forEach((child) => { + // child. + child.quantity += quantity * (old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0); + }); + } } else { const item = new Inventory({ sku, @@ -131,7 +168,24 @@ export class BillService { tax: sku.tax, discount, modifiers: [], + type: sku.isBundle ? 'bundle' : 'regular', }); + if (sku.isBundle) { + for (const bi of sku.bundleItems) { + const childItem = new Inventory({ + sku: new ProductQuery(bi), + quantity: quantity * bi.quantity, + price: bi.price, + isHappyHour: sku.hasHappyHour, + taxRate: bi.tax?.rate, + tax: bi.tax, + discount: 0, + modifiers: [], + type: 'bundle_item', + }); + item.children.push(childItem); + } + } newKot.inventories.push(item); this.modifierCategoryService.listForSku(sku.id as string).subscribe((result) => { if (result.reduce((a: number, c: ModifierCategory) => a + c.minimum, 0)) { @@ -165,51 +219,56 @@ export class BillService { }); } - addOne(item: BillViewItem): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + addOne(item: BillRow): void { + const old = item.inv as Inventory; old.quantity += 1; + if (old.children) { + old.children.forEach((child) => { + child.quantity += old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0; + }); + } this.displayBill(); } - quantity(item: BillViewItem, quantity: number): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + quantity(item: BillRow, quantity: number): void { + const old = item.inv as Inventory; old.quantity = quantity; + if (old.children) { + old.children.forEach((child) => { + child.quantity = (old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0) * quantity; + }); + } this.displayBill(); } - subtractOne(item: BillViewItem, canEdit: boolean): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; - if (item.quantity >= 1 || (canEdit && this.minimum(item.skuId as string, item.isHappyHour) >= 1)) { + subtractOne(item: BillRow, canEdit: boolean): void { + const newKot = item.kot as Kot; + const old = item.inv as Inventory; + if ( + old.quantity >= 1 || + (canEdit && this.minimum(item.inv?.sku.id as string, item.inv?.isHappyHour || false) >= 1) + ) { old.quantity -= 1; - } else if (item.quantity === 0) { + if (old.children) { + old.children.forEach((child) => { + child.quantity -= old.sku.bundleItems.find((bi) => bi.id === child.sku.id)?.quantity || 0; + }); + } + } else if (old.quantity === 0) { newKot.inventories.splice(newKot.inventories.indexOf(old), 1); } this.displayBill(); } - removeItem(item: BillViewItem): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + removeItem(item: BillRow): void { + const newKot = item.kot as Kot; + const old = item.inv as Inventory; newKot.inventories.splice(newKot.inventories.indexOf(old), 1); this.displayBill(); } - modifier(item: BillViewItem): void { - const newKot = this.bill.kots.find((k) => k.id === undefined) as Kot; - const old = newKot.inventories.find( - (x) => x.sku.id === item.skuId && x.isHappyHour === item.isHappyHour, - ) as Inventory; + modifier(item: BillRow): void { + const old = item.inv as Inventory; this.showModifier(old); } @@ -217,11 +276,14 @@ export class BillService { for (const kot of this.bill.kots) { const noDiscount = kot.inventories.filter((x) => x.isHappyHour).map((x) => x.sku.id as string); for (const inventory of kot.inventories) { - const e = discounts.find((d) => d.id === (inventory.sku.saleCategory as SaleCategory).id); - if (e === undefined || noDiscount.indexOf(inventory.sku.id as string) !== -1) { + if (noDiscount.indexOf(inventory.sku.id as string) !== -1) { continue; + // No discount on happy hour items + } + const d = discounts.find((d) => d.id === (inventory.sku.saleCategory as SaleCategory).id); + if (d) { + inventory.discount = d.discount; } - inventory.discount = e.discount; } } this.displayBill(); @@ -251,7 +313,7 @@ export class BillService { if (!this.isBillDiffent(item, guestBookId)) { return throwError(() => Error('Cannot print a blank KOT\nPlease add some products!')); } - if (!this.happyHourItemsBalanced() || this.happyHourItemsMoreThanRegular()) { + if (!this.happyHourItemsBalanced() || !this.regularItemsMoreThanHappyHour()) { return throwError(() => Error('Happy hour products are not balanced.')); } return this.ser @@ -265,7 +327,7 @@ export class BillService { if (skus === 0) { return throwError(() => Error('Cannot print a blank Bill\nPlease add some products!')); } - if (!this.happyHourItemsBalanced() || this.happyHourItemsMoreThanRegular()) { + if (!this.happyHourItemsBalanced() || !this.regularItemsMoreThanHappyHour()) { return throwError(() => Error('Happy hour products are not balanced.')); } return this.ser @@ -297,59 +359,6 @@ export class BillService { return this.ser.cancelBill(this.bill.id as string, reason, this.updateTable); } - updateAmounts() { - this.grossAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce((t, k) => k.inventories.reduce((a, c) => a + c.price * c.quantity, 0) + t, 0), - ), - ); - this.hhAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? c.price : 0) * c.quantity, 0) + t, - 0, - ), - ), - ); - this.discountAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => k.inventories.reduce((a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * c.discount, 0) + t, - 0, - ), - ), - ); - this.taxAmount.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => - k.inventories.reduce( - (a, c) => a + (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * c.taxRate, - 0, - ) + t, - 0, - ), - ), - ); - this.amountBs.next( - this.math.halfRoundEven( - this.bill.kots.reduce( - (t, k) => - k.inventories.reduce( - (a, c) => - a + - this.math.halfRoundEven( - (c.isHappyHour ? 0 : c.price) * c.quantity * (1 - c.discount) * (1 + c.taxRate), - 2, - ), - 0, - ) + t, - 0, - ), - ), - ); - } - splitBill(inventories: string[], table: Table): Observable { return this.ser.splitBill(this.bill.id as string, inventories, table, this.updateTable); } @@ -369,43 +378,49 @@ export class BillService { } private happyHourItemsBalanced(): boolean { - for (const kot of this.bill.kots) { + return this.bill.kots.every((kot) => { const happyHourItems = kot.inventories .filter((x) => x.isHappyHour) - .map((x) => ({ id: x.sku.id as string, quantity: x.quantity })); - for (const item of happyHourItems) { - const q = kot.inventories.find((x) => !x.isHappyHour && x.sku.id === item.id && x.quantity === item.quantity); - if (q === undefined) { - return false; - } - } - } - return true; - } - - private happyHourItemsMoreThanRegular(): boolean { - // This is for the whole bill. eg. Kot 1 => Reg 2 + HH 2; Kot 2 => Reg 4; Kot 3 => Reg - 4 - // This is pass okay in happy hours items balanced, but overall this is wrong. Hence this check - const invs: Record = {}; - for (const kot of this.bill.kots) { - for (const inventory of kot.inventories) { - const pid = inventory.sku.id as string; - if (invs[pid] === undefined) { - invs[pid] = { normal: 0, happy: 0 }; - } - if (inventory.isHappyHour) { - invs[pid].happy += inventory.quantity; - } else { - invs[pid].normal += inventory.quantity; - } - } - } - for (const [, value] of Object.entries(invs)) { - if (value.happy > value.normal) { + .map((x) => ({ id: x.sku.id as string, quantity: x.quantity })) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + const happyHourIds = happyHourItems.map((x) => x.id); + const rest = kot.inventories + .filter((x) => !x.isHappyHour && happyHourIds.indexOf(x.sku.id as string) !== -1) + .map((x) => ({ id: x.sku.id as string, quantity: x.quantity })) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + if (happyHourIds.length === 0) { return true; } - } - return false; + return ( + happyHourItems.length === rest.length && + happyHourItems.every((v, i) => v.id === rest[i].id && v.quantity === rest[i].quantity) + ); + }); + } + + private regularItemsMoreThanHappyHour(): boolean { + // This is for the whole bill. eg. Kot 1 => Reg 2 + HH 2; Kot 2 => Reg 4; Kot 3 => Reg - 4 + // This is pass okay in happy hours items balanced, but overall this is wrong. Hence this check + + const inventories = this.bill.kots + .flatMap((kot) => kot.inventories) + .reduce((acc: { id: string; quantity: number; isHappyHour: boolean }[], curr) => { + const existing = acc.find((x) => x.id === curr.sku.id && x.isHappyHour === curr.isHappyHour); + if (existing) { + existing.quantity += curr.quantity; + } else { + acc.push({ id: curr.sku.id as string, quantity: curr.quantity, isHappyHour: curr.isHappyHour }); + } + return acc; + }, []) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + const happyHourItems = inventories.filter((x) => x.isHappyHour); + const happyHourIds = happyHourItems.map((x) => x.id); + const rest = inventories.filter((x) => !x.isHappyHour && happyHourIds.indexOf(x.id as string) !== -1); + return ( + happyHourItems.length === rest.length && + happyHourItems.every((hh, index) => hh.id === rest[index].id && hh.quantity <= rest[index].quantity) + ); } public canDeactivate(): boolean { diff --git a/bookie/src/app/sales/bills/bill-row.ts b/bookie/src/app/sales/bills/bill-row.ts new file mode 100644 index 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 e5d9cc2..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 { ProductQuery } from '../../core/product-query'; import { Tax } from '../../core/tax'; -import { ProductLink } from './product-link'; export class Inventory { id: string | undefined; - sku: ProductLink; + clientId: string | undefined; + sku: ProductQuery; quantity: number; price: number; isHappyHour: boolean; - taxRate: number; + type: 'regular' | 'bundle' | 'bundle_item'; + parentId?: string; tax: Tax; + taxRate: number; discount: number; modifiers: Modifier[]; sortOrder: number; + children: Inventory[]; + + public get stableId(): string | undefined { + return this.id ?? this.clientId; + } public constructor(init?: Partial) { this.id = undefined; - this.sku = new ProductLink(); + if (!this.id) { + this.clientId = crypto.randomUUID(); + } + this.sku = new ProductQuery(); this.quantity = 0; this.price = 0; this.isHappyHour = false; + this.type = 'regular'; this.taxRate = 0; this.tax = new Tax(); this.discount = 0; this.modifiers = []; this.sortOrder = 0; + this.children = []; Object.assign(this, init); } } diff --git a/bookie/src/app/sales/home/sales-home.component.ts b/bookie/src/app/sales/home/sales-home.component.ts index 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 4b9838a..4ed0a8c 100644 --- a/bookie/src/app/sales/products/products.component.ts +++ b/bookie/src/app/sales/products/products.component.ts @@ -6,7 +6,6 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { ProductQuery } from '../../core/product-query'; import { BillService } from '../bill.service'; -import { ProductLink } from '../bills/product-link'; @Component({ selector: 'app-products', @@ -31,6 +30,6 @@ export class ProductsComponent implements OnInit { if (product.isNotAvailable) { return; } - this.bs.addSku(new ProductLink(product), 1, 0); + this.bs.addOneSku(product); } } diff --git a/bookie/src/app/sales/tables-dialog/tables-dialog.component.html b/bookie/src/app/sales/tables-dialog/tables-dialog.component.html index 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/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 From 7382d487ac490b76694bd76157c8be9805088bee Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 5 Feb 2026 01:12:56 +0000 Subject: [PATCH 4/7] Product Sale Report double counts the Bundle Items as it lists them as well as the bundle. Menu Engineering Report double counts the Bundle Items as it lists them as well as the bundle. All reports fixed. --- barker/barker/routers/__init__.py | 4 +-- .../routers/reports/beer_sale_report.py | 25 +++++-------------- .../barker/routers/reports/discount_report.py | 25 ++++--------------- .../reports/menu_engineering_report.py | 23 +++++------------ .../routers/reports/product_sale_report.py | 22 +++------------- barker/barker/routers/reports/sale_report.py | 23 +++++++---------- barker/barker/routers/reports/tax_report.py | 2 ++ barker/barker/routers/voucher/show.py | 3 +-- barker/barker/routers/voucher/split.py | 3 +-- barker/barker/schemas/voucher.py | 3 +-- 10 files changed, 37 insertions(+), 96 deletions(-) diff --git a/barker/barker/routers/__init__.py b/barker/barker/routers/__init__.py index 5c23500..eff9fde 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -42,14 +42,14 @@ def dates_overlap(start1: date | None, end1: date | None, start2: date | None, e return start1 <= end2 and start2 <= end1 -def _pv_active(date_: date): +def _pv_active(date_: date | Label[Any]): 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): +def _sv_active(date_: date | Label[Any]): return and_( 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/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..b6e2020 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 @@ -24,6 +23,7 @@ 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.user_token import UserToken +from .. import _pv_onclause, _sv_onclause from . import check_audit_permission, report_finish_date, report_start_date @@ -54,22 +54,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/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..184a3cb 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, Voucher.date >= start_date, Voucher.date <= finish_date, Voucher.voucher_type == VoucherType.REGULAR_BILL, diff --git a/barker/barker/routers/voucher/show.py b/barker/barker/routers/voucher/show.py index 2095f61..3de07e3 100644 --- a/barker/barker/routers/voucher/show.py +++ b/barker/barker/routers/voucher/show.py @@ -7,8 +7,6 @@ from fastapi import APIRouter, HTTPException, Security, status from sqlalchemy import Date, func, select from sqlalchemy.orm import Session, contains_eager -from barker.models.inventory_type import InventoryType - from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture @@ -16,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 diff --git a/barker/barker/routers/voucher/split.py b/barker/barker/routers/voucher/split.py index 806a045..03ce366 100644 --- a/barker/barker/routers/voucher/split.py +++ b/barker/barker/routers/voucher/split.py @@ -9,8 +9,6 @@ from sqlalchemy import Date, delete, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from barker.routers import _pv_onclause, _sv_onclause - from ...core.config import settings from ...core.security import get_current_active_user as get_user from ...db.session import SessionFuture @@ -22,6 +20,7 @@ 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, diff --git a/barker/barker/schemas/voucher.py b/barker/barker/schemas/voucher.py index 0569022..ae40eae 100644 --- a/barker/barker/schemas/voucher.py +++ b/barker/barker/schemas/voucher.py @@ -5,8 +5,7 @@ from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from barker.models.inventory_type import InventoryType - +from ..models.inventory_type import InventoryType from . import Daf, to_camel from .customer import CustomerLink from .modifier import ModifierLink From 91b3740ec0ee7a897c81e21bc6410584d2c51409 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 10 Feb 2026 18:00:03 +0000 Subject: [PATCH 5/7] Bundle item has an option to not print in the bill. Bills and Kots should print properly with bundles --- .python-version | 1 + README.md | 15 ++++- barker/alembic/versions/32c508eed4df_skus.py | 4 +- .../alembic/versions/367ecf7b898f_bundles.py | 46 ++++++++++++- .../alembic/versions/7e9944b430d6_booking.py | 2 +- barker/barker/models/bundle_item.py | 5 +- barker/barker/models/customer.py | 13 +++- barker/barker/models/guest_book.py | 2 +- barker/barker/models/inventory.py | 4 +- barker/barker/printing/__init__.py | 61 ++++------------- barker/barker/printing/bill.py | 65 +++++++++++++------ barker/barker/printing/cashier_report.py | 2 - barker/barker/printing/discount_report.py | 2 - barker/barker/printing/kot.py | 30 +++++---- barker/barker/printing/product_sale_report.py | 2 - barker/barker/printing/sale_report.py | 2 - barker/barker/routers/bundle.py | 29 +++++---- barker/barker/routers/reports/tax_report.py | 2 +- barker/barker/routers/voucher/change.py | 2 +- barker/barker/routers/voucher/save.py | 4 +- barker/barker/routers/voucher/update.py | 12 ++-- barker/barker/schemas/bundle.py | 4 ++ barker/pyproject.toml | 2 +- .../bundle-detail-dialog.component.html | 1 + .../bundle-detail-dialog.component.ts | 10 ++- .../bundle-detail.component.html | 37 +++++++++-- .../bundle-detail/bundle-detail.component.ts | 26 +++++++- .../bundle-list/bundle-list.component.html | 11 ++++ .../bundle-list/bundle-list.component.ts | 3 +- bookie/src/app/bundles/bundle.ts | 4 ++ bookie/src/app/bundles/bundles.routes.ts | 3 + .../customer-detail.component.ts | 1 - frank/config.py | 2 +- frank/pyproject.toml | 9 +-- frank/run_worker.py | 9 +++ frank/worker-start.sh | 4 +- 36 files changed, 290 insertions(+), 141 deletions(-) create mode 100644 .python-version create mode 100644 frank/run_worker.py 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/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 index fc76723..affe8fa 100644 --- a/barker/alembic/versions/367ecf7b898f_bundles.py +++ b/barker/alembic/versions/367ecf7b898f_bundles.py @@ -28,6 +28,7 @@ def upgrade(): 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( @@ -47,7 +48,7 @@ def upgrade(): ) inventory_type = sa.Enum("regular", "bundle", "bundle_item", name="inventorytype") - # inventory_type.create(op.get_bind()) + 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") @@ -82,6 +83,45 @@ def upgrade(): ) ) + 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") @@ -101,3 +141,7 @@ def downgrade(): 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/models/bundle_item.py b/barker/barker/models/bundle_item.py index a66cba8..a2911bc 100644 --- a/barker/barker/models/bundle_item.py +++ b/barker/barker/models/bundle_item.py @@ -6,7 +6,7 @@ from datetime import date from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import Date, ForeignKey, Numeric, Uuid, func, text +from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Uuid, func, text from sqlalchemy.dialects import postgresql from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -28,6 +28,7 @@ class BundleItem: 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) @@ -49,6 +50,7 @@ class BundleItem: self, quantity: Decimal, sale_price: Decimal, + print_in_bill: bool, bundle_id: uuid.UUID, item_id: uuid.UUID, bundle: StockKeepingUnit | None = None, @@ -59,6 +61,7 @@ class BundleItem: ): 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: 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 d5d9e99..bdd74b5 100644 --- a/barker/barker/models/inventory.py +++ b/barker/barker/models/inventory.py @@ -36,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", "type", "parent_id"),) + __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) 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/bundle.py b/barker/barker/routers/bundle.py index 574e175..7c90c9a 100644 --- a/barker/barker/routers/bundle.py +++ b/barker/barker/routers/bundle.py @@ -22,6 +22,7 @@ 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 @@ -41,6 +42,7 @@ def bundle_blank() -> schemas.BundleBlank: name="", units="", sale_price=Decimal("0.00"), + sale_category=None, has_happy_hour=False, is_not_available=False, sort_order=0, @@ -56,6 +58,7 @@ def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemM 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, @@ -73,6 +76,7 @@ def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemM item_id=bi.item_id, sale_price=bi.sale_price, quantity=bi.quantity, + print_in_bill=bi.print_in_bill, ) for bi in items ], @@ -97,13 +101,6 @@ def save( ) sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) - sale_category_id = db.execute( - select(ProductVersion.sale_category_id) - .join(Product, onclause=_pv_onclause(date_)) - .join(Product.skus) - .where(StockKeepingUnit.id == data.items[0].item_id) - ).scalar_one() - product = Product(sort_order=data.sort_order) db.add(product) db.flush() @@ -113,7 +110,7 @@ def save( product=product, name=data.name, fraction_units=data.units, # per your rule - sale_category_id=sale_category_id, + sale_category_id=data.sale_category.id_, sale_category=None, valid_from=date_, valid_till=None, @@ -151,6 +148,7 @@ def save( 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, ) @@ -207,7 +205,7 @@ def update_route( sku = sv.sku product = sku.product pv = product.versions[0] - pv_changed = pv.name != data.name + 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 @@ -216,6 +214,7 @@ def update_route( 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( @@ -224,7 +223,7 @@ def update_route( product=product, name=data.name, fraction_units=pv.fraction_units, - sale_category_id=pv.sale_category_id, + sale_category_id=data.sale_category.id_, valid_from=date_, valid_till=None, ) @@ -290,6 +289,7 @@ def update_route( 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, ) @@ -305,6 +305,7 @@ def update_route( 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, ) @@ -362,6 +363,7 @@ def show_list( .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 @@ -371,7 +373,8 @@ def show_list( contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), contains_eager(BundleItemModel.item) .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), ) ) .unique() @@ -424,6 +427,7 @@ def show_id( .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 @@ -433,7 +437,8 @@ def show_id( contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), contains_eager(BundleItemModel.item) .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category), ) ) .unique() diff --git a/barker/barker/routers/reports/tax_report.py b/barker/barker/routers/reports/tax_report.py index 184a3cb..957a373 100644 --- a/barker/barker/routers/reports/tax_report.py +++ b/barker/barker/routers/reports/tax_report.py @@ -61,7 +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, + 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/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 8bae387..755bf06 100644 --- a/barker/barker/routers/voucher/save.py +++ b/barker/barker/routers/voucher/save.py @@ -170,7 +170,9 @@ def do_save( inv.modifiers.append(mod) db.add(mod) if data_inv.type_ == InventoryType.bundle: - if data_inv.price != sum(child.price * child.quantity for child in data_inv.children): + 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.", diff --git a/barker/barker/routers/voucher/update.py b/barker/barker/routers/voucher/update.py index 76d8de9..2a5361d 100644 --- a/barker/barker/routers/voucher/update.py +++ b/barker/barker/routers/voucher/update.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import func, select +from sqlalchemy import Date, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, contains_eager @@ -75,6 +75,9 @@ 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() @@ -84,8 +87,7 @@ def update_route( 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, product_version_onclause, sku_version_onclause) + item = get_voucher(id_, db, _pv_onclause(day), _sv_onclause(day)) check_permissions(item, voucher_type, user.permissions) if item.voucher_type != VoucherType.KOT: @@ -210,7 +212,9 @@ def update_route( inv.modifiers.append(mod) db.add(mod) if data_inv.type_ == InventoryType.bundle: - if data_inv.price != sum(child.price * child.quantity for child in data_inv.children): + 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.", diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py index 7d21442..2c7c9f6 100644 --- a/barker/barker/schemas/bundle.py +++ b/barker/barker/schemas/bundle.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field from . import Daf, to_camel from .menu_category import MenuCategoryLink +from .sale_category import SaleCategoryLink class BundleItem(BaseModel): @@ -15,6 +16,7 @@ class BundleItem(BaseModel): 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) @@ -23,6 +25,7 @@ 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 @@ -46,6 +49,7 @@ 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 diff --git a/barker/pyproject.toml b/barker/pyproject.toml index d46f78a..2556101 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"} 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 index 54943ae..f056aa1 100644 --- a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.html @@ -22,6 +22,7 @@ Sale Price + Print in Bill?
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 index a185570..3c078c3 100644 --- a/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail-dialog.component.ts @@ -1,8 +1,9 @@ -import { AsyncPipe } from '@angular/common'; +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'; @@ -18,9 +19,10 @@ import { BundleItem } from '../bundle'; styleUrls: ['./bundle-detail-dialog.component.css'], standalone: true, imports: [ - AsyncPipe, + CommonModule, MatAutocompleteModule, MatButtonModule, + MatCheckboxModule, MatDialogModule, MatFormFieldModule, MatInputModule, @@ -40,6 +42,7 @@ export class BundleDetailDialogComponent implements OnInit { product: FormControl; quantity: FormControl; salePrice: FormControl; + printInBill: FormControl; }>; constructor() { @@ -47,6 +50,7 @@ export class BundleDetailDialogComponent implements OnInit { 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( @@ -70,6 +74,7 @@ export class BundleDetailDialogComponent implements OnInit { 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; @@ -104,6 +109,7 @@ export class BundleDetailDialogComponent implements OnInit { 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.html b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html index 98fbb18..edfad6a 100644 --- a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html @@ -14,13 +14,13 @@
- + Sale Price Computed from bundle items - + Menu Category @for (mc of menuCategories; track mc) { @@ -31,8 +31,26 @@ - Has Happy Hour? - Not Available? + + 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

@@ -63,6 +81,7 @@ Sale Price + Print in Bill? @@ -94,6 +113,16 @@
+ + + Print in Bill + + + {{ row.printInBill ? 'visibility' : 'visibility_off' }} + + + + Action diff --git a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts index 4d58a35..6b5a60d 100644 --- a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts @@ -17,6 +17,7 @@ import { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged 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'; @@ -59,6 +60,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { units: FormControl; salePrice: FormControl; menuCategory: FormControl; + saleCategory: FormControl; hasHappyHour: FormControl; isNotAvailable: FormControl; @@ -67,10 +69,12 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { itemId: FormControl; quantity: FormControl; salePrice: FormControl; + printInBill: FormControl; }>; }>; menuCategories: MenuCategory[] = []; + saleCategories: SaleCategory[] = []; public items$ = new BehaviorSubject([]); dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$); @@ -79,7 +83,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { itemProduct: ProductQuery | null = null; itemProducts: Observable; - displayedColumns = ['name', 'quantity', 'salePrice', 'action']; + displayedColumns = ['name', 'quantity', 'salePrice', 'printInBill', 'action']; constructor() { this.form = new FormGroup({ @@ -87,6 +91,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { 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 }), @@ -94,6 +99,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { 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( @@ -119,8 +125,10 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { const data = value as { item: Bundle; menuCategories: MenuCategory[]; + saleCategories: SaleCategory[]; }; this.menuCategories = data.menuCategories; + this.saleCategories = data.saleCategories; this.showItem(data.item); }); } @@ -158,6 +166,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { 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, @@ -165,6 +174,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { itemId: '', quantity: 1, salePrice: 0, + printInBill: true, }, }); this.itemProduct = null; @@ -217,6 +227,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { name: this.itemProduct?.name ?? '', quantity, salePrice, + printInBill: v.printInBill || false, }); this.item.items.push(bi); @@ -315,6 +326,19 @@ export class BundleDetailComponent implements OnInit, AfterViewInit { } 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 = []; diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.html b/bookie/src/app/bundles/bundle-list/bundle-list.component.html index e7fd27e..8eceec2 100644 --- a/bookie/src/app/bundles/bundle-list/bundle-list.component.html +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.html @@ -55,12 +55,23 @@ + + + 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' }}
}
diff --git a/bookie/src/app/bundles/bundle-list/bundle-list.component.ts b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts index 17cd7c9..a09ff96 100644 --- a/bookie/src/app/bundles/bundle-list/bundle-list.component.ts +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts @@ -51,7 +51,7 @@ export class BundleListComponent implements OnInit { list: Bundle[] = []; menuCategories: MenuCategory[] = []; - displayedColumns: string[] = ['name', 'price', 'menuCategory', 'info', 'items']; + displayedColumns: string[] = ['name', 'price', 'menuCategory', 'saleCategory', 'info', 'items']; constructor() { this.form = new FormGroup({ @@ -91,6 +91,7 @@ export class BundleListComponent implements OnInit { Units: 'units', Price: 'salePrice', MenuCategory: 'menuCategory', + SaleCategory: 'saleCategory', Items: 'items', }; diff --git a/bookie/src/app/bundles/bundle.ts b/bookie/src/app/bundles/bundle.ts index 380c531..5f8cb25 100644 --- a/bookie/src/app/bundles/bundle.ts +++ b/bookie/src/app/bundles/bundle.ts @@ -1,4 +1,5 @@ import { MenuCategory } from '../core/menu-category'; +import { SaleCategory } from '../core/sale-category'; export class BundleItem { id?: string; @@ -6,12 +7,14 @@ export class BundleItem { 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); } } @@ -30,6 +33,7 @@ export class Bundle { sortOrder: number; menuCategory?: MenuCategory | null; + saleCategory?: SaleCategory | null; items: BundleItem[]; public constructor(init?: Partial) { diff --git a/bookie/src/app/bundles/bundles.routes.ts b/bookie/src/app/bundles/bundles.routes.ts index a20c3eb..3e8c316 100644 --- a/bookie/src/app/bundles/bundles.routes.ts +++ b/bookie/src/app/bundles/bundles.routes.ts @@ -2,6 +2,7 @@ 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'; @@ -26,6 +27,7 @@ export const routes: Routes = [ resolve: { item: bundleResolver, // returns new Bundle() because id === null (no param here) menuCategories: menuCategoryListResolver, + saleCategories: saleCategoryListResolver, }, }, { @@ -36,6 +38,7 @@ export const routes: Routes = [ resolve: { item: bundleResolver, menuCategories: menuCategoryListResolver, + saleCategories: saleCategoryListResolver, }, }, ]; 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/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 From dc34a78e066145e643d022a8347870146be219fb Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 10 Feb 2026 18:12:52 +0000 Subject: [PATCH 6/7] Cleanup --- Dockerfile | 10 +--------- barker/barker/routers/bundle.py | 8 ++++++-- barker/barker/tasks/__init__.py | 0 barker/barker/tasks/printing.py | 33 ------------------------------- barker/barker/worker.py | 35 --------------------------------- barker/pyproject.toml | 7 ++++++- barker/worker-start.sh | 5 ----- 7 files changed, 13 insertions(+), 85 deletions(-) delete mode 100644 barker/barker/tasks/__init__.py delete mode 100644 barker/barker/tasks/printing.py delete mode 100644 barker/barker/worker.py delete mode 100755 barker/worker-start.sh 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/barker/barker/routers/bundle.py b/barker/barker/routers/bundle.py index 7c90c9a..0c15324 100644 --- a/barker/barker/routers/bundle.py +++ b/barker/barker/routers/bundle.py @@ -99,7 +99,9 @@ def save( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Not enough bundle items.", ) - sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) + sale_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) @@ -174,7 +176,9 @@ def update_route( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Not enough bundle items.", ) - sale_price = round(sum(round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), 2) + sale_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( diff --git a/barker/barker/tasks/__init__.py b/barker/barker/tasks/__init__.py deleted file mode 100644 index e69de29..0000000 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 2556101..53f5e36 100644 --- a/barker/pyproject.toml +++ b/barker/pyproject.toml @@ -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 From e7e49019c4b934a0f92aeee7aa91779714a6cb5b Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Wed, 11 Feb 2026 04:41:22 +0000 Subject: [PATCH 7/7] Schemas everywhere mypy and ruff fully compliant Type hints everywhere --- barker/barker/models/modifier_category.py | 2 +- barker/barker/models/permission.py | 2 +- barker/barker/models/product.py | 2 +- barker/barker/models/role.py | 2 +- barker/barker/models/user.py | 2 +- barker/barker/routers/__init__.py | 12 ++--- barker/barker/routers/device.py | 3 +- barker/barker/routers/header_footer.py | 16 +++---- barker/barker/routers/health.py | 7 +-- barker/barker/routers/modifier.py | 3 +- barker/barker/routers/modifier_category.py | 9 ++-- barker/barker/routers/product.py | 5 ++- .../routers/reports/product_sale_report.py | 3 +- .../routers/reports/product_updates_report.py | 22 ++++++---- barker/barker/routers/role.py | 4 +- barker/barker/routers/sale_category.py | 3 +- barker/barker/routers/section_printer.py | 6 ++- barker/barker/routers/setting.py | 7 +-- barker/barker/routers/table.py | 44 +++++++++---------- barker/barker/routers/tax.py | 3 +- barker/barker/routers/temporal_product.py | 4 +- barker/barker/routers/user.py | 4 +- barker/barker/routers/voucher/__init__.py | 13 ++++-- barker/barker/routers/voucher/show.py | 22 +++++----- barker/barker/routers/voucher/update.py | 4 +- .../barker/schemas/beer_consumption_report.py | 18 +++++--- .../barker/schemas/bill_settlement_report.py | 9 ++-- barker/barker/schemas/cashier_report.py | 7 ++- barker/barker/schemas/db_setting.py | 19 ++++++++ barker/barker/schemas/device.py | 8 ++-- barker/barker/schemas/discount_report.py | 7 ++- barker/barker/schemas/guest_book.py | 17 +++---- barker/barker/schemas/health_response.py | 6 +++ .../barker/schemas/menu_engineering_report.py | 7 ++- barker/barker/schemas/product.py | 8 ++-- barker/barker/schemas/product_sale_report.py | 16 +++++-- .../barker/schemas/product_updates_report.py | 35 +++++++++++++++ barker/barker/schemas/running_table.py | 43 ++++++++++++++++++ barker/barker/schemas/sale_report.py | 7 ++- barker/barker/schemas/stock_keeping_unit.py | 8 ++-- barker/barker/schemas/tax_report.py | 7 ++- barker/barker/schemas/temporal_product.py | 12 ++--- .../barker/schemas/update_product_prices.py | 5 +-- barker/barker/schemas/user.py | 5 +-- barker/barker/schemas/voucher_out.py | 16 +++---- 45 files changed, 298 insertions(+), 166 deletions(-) create mode 100644 barker/barker/schemas/db_setting.py create mode 100644 barker/barker/schemas/health_response.py create mode 100644 barker/barker/schemas/product_updates_report.py create mode 100644 barker/barker/schemas/running_table.py 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/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/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/routers/__init__.py b/barker/barker/routers/__init__.py index eff9fde..5f64f11 100644 --- a/barker/barker/routers/__init__.py +++ b/barker/barker/routers/__init__.py @@ -1,7 +1,7 @@ from datetime import UTC, date, datetime, timedelta from typing import Any -from sqlalchemy import Label, and_, or_ +from sqlalchemy import ColumnElement, Label, and_, or_ from ..core.config import settings from ..models.bundle_item import BundleItem @@ -42,28 +42,28 @@ def dates_overlap(start1: date | None, end1: date | None, start2: date | None, e return start1 <= end2 and start2 <= end1 -def _pv_active(date_: date | Label[Any]): +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]): +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): +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]): +def _pv_onclause(date_: date | Label[Any]) -> ColumnElement[bool]: return and_( ProductVersion.product_id == Product.id, or_( @@ -77,7 +77,7 @@ def _pv_onclause(date_: date | Label[Any]): ) -def _sv_onclause(date_: date | Label[Any]): +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 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 76d0a1b..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), @@ -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 726a552..1e4408f 100644 --- a/barker/barker/routers/product.py +++ b/barker/barker/routers/product.py @@ -29,6 +29,7 @@ from ..schemas import product as schemas from ..schemas.menu_category import MenuCategoryLink 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 _pv_onclause, _sv_onclause, effective_date @@ -610,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/product_sale_report.py b/barker/barker/routers/reports/product_sale_report.py index b6e2020..ece776c 100644 --- a/barker/barker/routers/reports/product_sale_report.py +++ b/barker/barker/routers/reports/product_sale_report.py @@ -21,7 +21,8 @@ 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 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/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 7acbab3..c8dc23e 100644 --- a/barker/barker/routers/voucher/__init__.py +++ b/barker/barker/routers/voucher/__init__.py @@ -4,7 +4,7 @@ from datetime import datetime from decimal import Decimal from fastapi import HTTPException, status -from sqlalchemy import func, select +from sqlalchemy import ColumnElement, func, select from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql import expression @@ -188,7 +188,12 @@ def happy_hour_items_more_than_regular(kots: list[schemas.Kot]) -> bool: return any(value["happy"] > value["normal"] for value in inventories.values()) -def get_voucher(voucher_id: uuid.UUID, db: Session, product_version_onclause, sku_version_onclause) -> Voucher: +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) @@ -230,8 +235,8 @@ def get_voucher(voucher_id: uuid.UUID, db: Session, product_version_onclause, sk def get_sku( sku_id: uuid.UUID, db: Session, - product_version_onclause, - sku_version_onclause, + product_version_onclause: ColumnElement[bool], + sku_version_onclause: ColumnElement[bool], ) -> StockKeepingUnit: sku: StockKeepingUnit = ( db.execute( diff --git a/barker/barker/routers/voucher/show.py b/barker/barker/routers/voucher/show.py index 3de07e3..c2c486e 100644 --- a/barker/barker/routers/voucher/show.py +++ b/barker/barker/routers/voucher/show.py @@ -24,21 +24,21 @@ 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, + Inventory as InventoryOut, ) from ...schemas.voucher_out import ( - Inventory as InventoryOut, + InventoryProduct, + ProductMenuCategory, + ProductSaleCategory, + VoucherBlank, + VoucherOut, ) from ...schemas.voucher_out import ( Kot as KotOut, diff --git a/barker/barker/routers/voucher/update.py b/barker/barker/routers/voucher/update.py index 2a5361d..bc6aedb 100644 --- a/barker/barker/routers/voucher/update.py +++ b/barker/barker/routers/voucher/update.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import Date, func, select +from sqlalchemy import ColumnElement, Date, func, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, contains_eager @@ -43,7 +43,7 @@ from ...schemas.user_token import UserToken router = APIRouter() -def discount_limit(sku_id: uuid.UUID, db: Session, product_version_onclause) -> Decimal: +def discount_limit(sku_id: uuid.UUID, db: Session, product_version_onclause: ColumnElement[bool]) -> Decimal: sku: StockKeepingUnit = ( db.execute( select(StockKeepingUnit) diff --git a/barker/barker/schemas/beer_consumption_report.py b/barker/barker/schemas/beer_consumption_report.py index 41c1647..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 Annotated, 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,7 +26,7 @@ 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) @@ -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/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/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 da5b082..ada3acc 100644 --- a/barker/barker/schemas/device.py +++ b/barker/barker/schemas/device.py @@ -1,9 +1,9 @@ import uuid from datetime import datetime -from typing import Annotated, 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 @@ -24,11 +24,11 @@ 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") 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 0d66df7..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 Annotated, Any +from typing import Annotated from pydantic import ( BaseModel, ConfigDict, Field, + SerializationInfo, field_serializer, field_validator, model_validator, @@ -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/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/product.py b/barker/barker/schemas/product.py index 325d83f..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 Annotated, 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 @@ -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_sale_report.py b/barker/barker/schemas/product_sale_report.py index e13473b..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 Annotated, 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 @@ -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/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_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/stock_keeping_unit.py b/barker/barker/schemas/stock_keeping_unit.py index d3a3099..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 Annotated, 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 @@ -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/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 eb801d9..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 Annotated, 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 @@ -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,7 +46,7 @@ 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") @@ -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,7 +91,7 @@ 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/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_out.py b/barker/barker/schemas/voucher_out.py index 0b02949..3ed3d12 100644 --- a/barker/barker/schemas/voucher_out.py +++ b/barker/barker/schemas/voucher_out.py @@ -4,7 +4,7 @@ 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 @@ -110,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") @@ -149,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")