From ff537edb06d6258a54f465fc4616b1e55b690228 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 27 Aug 2026 11:38:38 +0000 Subject: [PATCH] fds --- .vscode/settings.json | 13 +- Dockerfile | 2 +- ansible/files/Caddyfile.j2 | 23 +- barker/.vscode/settings.json | 22 + barker/barker/routers/bundle.py | 633 ++++++++++-------- barker/barker/schemas/bundle.py | 4 +- barker/barker/schemas/menu_category.py | 2 +- .../bundle-detail-dialog.component.ts | 7 +- .../bundle-detail.component.html | 12 +- .../bundle-detail/bundle-detail.component.ts | 15 +- .../bundle-list/bundle-list.component.html | 4 +- .../bundle-list/bundle-list.component.ts | 2 +- bookie/src/app/bundles/bundle.ts | 5 +- .../customer-list/customer-list.component.ts | 9 +- .../guest-book-list.component.ts | 28 +- .../src/app/guest-book/guest-book.service.ts | 5 +- .../choose-customer.component.html | 22 +- .../choose-customer.component.ts | 26 +- .../discount/customer-discounts.service.ts | 15 +- 19 files changed, 456 insertions(+), 393 deletions(-) create mode 100644 barker/.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 8c97ed6..202ab56 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { - "python.defaultInterpreterPath": "${workspaceFolder}/barker/.venv/bin/python", + "python.defaultInterpreterPath": "/home/tanshu/programming/barker/barker/.venv/bin/python", + "python.interpreterPath": "/home/tanshu/programming/barker/barker/.venv/bin/python", + "python.venvFolders": [ + "barker/.venv", + ".venv" + ], + "python.venvPath": "${workspaceFolder}/barker", "python.terminal.activateEnvironment": true, // Quality-of-life /* --- Pylance: keep IntelliSense, disable type checking --- */ @@ -7,6 +13,11 @@ // "python.analysis.diagnosticMode": "openFilesOnly", "python.analysis.typeCheckingMode": "basic", "python.analysis.extraPaths": [ + "${workspaceFolder}/barker", + "./barker" + ], + "python.autoComplete.extraPaths": [ + "${workspaceFolder}/barker", "./barker" ], // Ruff (you have ruff installed via uv) diff --git a/Dockerfile b/Dockerfile index 41a2a83..194308c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,7 +65,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-install-project COPY /barker ./ -COPY --from=builder /frontend/browser /app/static +COPY --from=builder /frontend/browser /app/frontend # Sync the project # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers diff --git a/ansible/files/Caddyfile.j2 b/ansible/files/Caddyfile.j2 index f7a830c..770221d 100644 --- a/ansible/files/Caddyfile.j2 +++ b/ansible/files/Caddyfile.j2 @@ -1,24 +1,3 @@ {{ host }} { - # Match and proxy API routes - @apiRoutes { - path_regexp ^/(api|token|refresh|db-image) - } - handle @apiRoutes { - reverse_proxy @apiRoutes {{ host_directory }}:80 - } - - # Match requests that end with .js, .css, .ico, or .html - @staticFiles { - path_regexp \.(js|css|ico|html)$ - } - handle @staticFiles { - rewrite * /static{uri} - reverse_proxy {{ host_directory }}:80 - } - - # All other frontend routes → /static/index.html - handle { - rewrite * /static/index.html - reverse_proxy {{ host_directory }}:80 - } + reverse_proxy {{ host_directory }}:80 } diff --git a/barker/.vscode/settings.json b/barker/.vscode/settings.json new file mode 100644 index 0000000..4565bb6 --- /dev/null +++ b/barker/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "python.defaultInterpreterPath": "/home/tanshu/programming/barker/barker/.venv/bin/python", + "python.interpreterPath": "/home/tanshu/programming/barker/barker/.venv/bin/python", + "python.venvFolders": [ + ".venv" + ], + "python.terminal.activateEnvironment": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.extraPaths": [ + "${workspaceFolder}" + ], + "python.autoComplete.extraPaths": [ + "${workspaceFolder}" + ], + "editor.formatOnSave": true, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "always" + } +} diff --git a/barker/barker/routers/bundle.py b/barker/barker/routers/bundle.py index 8fd06c4..ceb4cfb 100644 --- a/barker/barker/routers/bundle.py +++ b/barker/barker/routers/bundle.py @@ -1,13 +1,14 @@ import uuid +from collections import defaultdict from collections.abc import Sequence from datetime import date, timedelta from decimal import Decimal from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Security, status -from sqlalchemy import Date, func, or_, select -from sqlalchemy.orm import contains_eager +from sqlalchemy import Date, Select, func, select +from sqlalchemy.orm import Session, contains_eager from ..core.config import settings from ..core.security import get_current_active_user as get_user @@ -17,6 +18,7 @@ from ..models.inventory import Inventory 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 @@ -24,20 +26,95 @@ 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 +from . import _bundle_active, _pv_onclause, _sv_onclause, effective_date +from .product import query_product_info router = APIRouter() +# Bundle-specific constants: bundles always have fraction=1, yield=1, cost=0. FRACTION_1 = Decimal("1") YIELD_1 = Decimal("1") COST_0 = Decimal("0") - -# ---------- helpers ---------- +# Rounding precision for monetary amounts and quantities. +PRICE_PLACES = Decimal("0.01") +QTY_PLACES = Decimal("0.00001") -def bundle_blank() -> schemas.BundleBlank: +def _round_price(value: Decimal) -> Decimal: + return value.quantize(PRICE_PLACES) + + +def _round_qty(value: Decimal) -> Decimal: + return value.quantize(QTY_PLACES) + + +# ---------- query builders ---------- + + +def _select_bundle_header(date_: date, sku_id: uuid.UUID | None = None) -> Select[tuple[SkuVersion]]: + """Build the common query to load a bundle's SkuVersion with eagerly-loaded + SKU → Product → ProductVersion and MenuCategory chains. + + When *sku_id* is ``None`` the query returns **all** active bundles (for the + list endpoint); otherwise it filters to a single bundle. + """ + stmt = ( + select(SkuVersion) + .join(StockKeepingUnit, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .join(SkuVersion.menu_category) + .where(StockKeepingUnit.is_bundle == True) # noqa: E712 + .options( + contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions), + contains_eager(SkuVersion.menu_category), + ) + ) + if sku_id is not None: + stmt = stmt.where(StockKeepingUnit.id == sku_id) + return stmt + + +def _select_bundle_items( + date_: date, + bundle_sku_ids: Sequence[uuid.UUID], +) -> Select[tuple[BundleItemModel]]: + """Build the common query to load active BundleItem rows for one or more + bundles, eagerly loading the child SKU's version chain (menu category, + product version, sale category, tax). + """ + return ( + select(BundleItemModel) + .join(BundleItemModel.item) + .join(SkuVersion, onclause=_sv_onclause(date_)) + .join(SkuVersion.menu_category) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .join(ProductVersion.sale_category) + .join(SaleCategory.tax) + .where( + BundleItemModel.bundle_id.in_(bundle_sku_ids), + _bundle_active(date_), + ) + .options( + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.versions) + .contains_eager(SkuVersion.menu_category), + contains_eager(BundleItemModel.item) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.sale_category) + .contains_eager(SaleCategory.tax), + ) + ) + + +# ---------- response helpers ---------- + + +def _bundle_blank() -> schemas.BundleBlank: return schemas.BundleBlank( name="", units="", @@ -72,8 +149,7 @@ def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemM items=[ schemas.BundleItem( id_=bi.id, - name=f"{bi.item.product.versions[0].name} ({bi.item.versions[0].units})", - item_id=bi.item_id, + sku=query_product_info(bi.item.versions[0], False, []), sale_price=bi.sale_price, quantity=bi.quantity, print_in_bill=bi.print_in_bill, @@ -83,6 +159,198 @@ def _bundle_info(pv: ProductVersion, sv: SkuVersion, items: Sequence[BundleItemM ) +# ---------- temporal update helpers ---------- + + +def _compute_sale_price(items: list[schemas.BundleItem]) -> Decimal: + """Derive the bundle sale price from its items: Σ(item_price × qty).""" + return _round_price( + sum( + (_round_price(it.sale_price) * _round_qty(it.quantity) for it in items), + start=Decimal(0), + ) + ) + + +def _update_product_version( + db: Session, + pv: ProductVersion, + date_: date, + data: schemas.BundleIn, +) -> None: + """Temporally update the ProductVersion if name, sale_category, or + fraction_units changed. Edits in-place when *pv* was created today, + otherwise closes it and inserts a new version. + """ + pv_changed = ( + pv.name != data.name or pv.sale_category_id != data.sale_category.id_ or pv.fraction_units != data.units + ) + if not pv_changed: + return + + if pv.valid_from == date_: + pv.name = data.name + pv.sale_category_id = data.sale_category.id_ + pv.fraction_units = data.units + else: + pv.valid_till = date_ - timedelta(days=1) + db.add( + ProductVersion( + product_id=pv.product_id, + name=data.name, + fraction_units=data.units, + sale_category_id=data.sale_category.id_, + valid_from=date_, + valid_till=None, + ) + ) + + +def _update_sku_version( + db: Session, + sv: SkuVersion, + date_: date, + data: schemas.BundleIn, + sale_price: Decimal, +) -> None: + """Temporally update the SkuVersion if units, sale_price, happy-hour, or + menu_category changed. Edits in-place when *sv* was created today, + otherwise closes it and inserts a new version. + """ + sv_changed = ( + sv.units != data.units + or _round_price(Decimal(sv.sale_price)) != sale_price + or sv.has_happy_hour != data.has_happy_hour + or sv.menu_category_id != data.menu_category.id_ + ) + if not sv_changed: + return + + 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_ + else: + sv.valid_till = date_ - timedelta(days=1) + db.add( + SkuVersion( + sku_id=sv.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, + ) + ) + + +def _sync_bundle_items( + db: Session, + bundle_sku_id: uuid.UUID, + date_: date, + items: list[schemas.BundleItem], +) -> None: + """Reconcile the incoming item list against the existing active rows. + + For each existing row that is no longer in the incoming list, it is either + hard-deleted (if created today) or temporally closed. New items are + inserted and changed items are updated with the same temporal logic. + """ + existing = ( + db.execute( + select(BundleItemModel).where( + BundleItemModel.bundle_id == bundle_sku_id, + _bundle_active(date_), + ) + ) + .scalars() + .all() + ) + existing_by_id: dict[uuid.UUID, BundleItemModel] = {x.id: x for x in existing} + existing_by_item: dict[uuid.UUID, BundleItemModel] = {x.item_id: x for x in existing} + + def _find_existing(it: schemas.BundleItem) -> BundleItemModel | None: + if it.id_ is not None: + return existing_by_id.get(it.id_) + return existing_by_item.get(it.sku.id_) + + # Identify which existing rows are still matched by incoming items. + matched_ids: set[uuid.UUID] = set() + for it in items: + ex = _find_existing(it) + if ex is not None: + matched_ids.add(ex.id) + + # Remove rows that are no longer in the incoming list. + for ex in existing: + if ex.id not in matched_ids: + if ex.valid_from == date_: + db.delete(ex) + else: + ex.valid_till = date_ - timedelta(days=1) + + # Add new rows or update changed ones. + for it in items: + price = _round_price(it.sale_price) + qty = _round_qty(it.quantity) + ex = _find_existing(it) + + if ex is None: + db.add( + BundleItemModel( + bundle_id=bundle_sku_id, + item_id=it.sku.id_, + sale_price=price, + quantity=qty, + print_in_bill=it.print_in_bill, + valid_from=date_, + valid_till=None, + ) + ) + continue + + item_changed = ( + ex.item_id != it.sku.id_ + or _round_price(Decimal(ex.sale_price)) != price + or _round_qty(Decimal(ex.quantity)) != qty + or ex.print_in_bill != it.print_in_bill + ) + if not item_changed: + continue + + if ex.valid_from == date_: + ex.item_id = it.sku.id_ + ex.sale_price = price + ex.quantity = qty + ex.print_in_bill = it.print_in_bill + else: + ex.valid_till = date_ - timedelta(days=1) + db.add( + BundleItemModel( + bundle_id=bundle_sku_id, + item_id=it.sku.id_, + sale_price=price, + quantity=qty, + print_in_bill=it.print_in_bill, + valid_from=date_, + valid_till=None, + ) + ) + + +def _close_or_delete(db: Session, entity: ProductVersion | SkuVersion | BundleItemModel, date_: date) -> None: + """Hard-delete if created today, otherwise temporally close.""" + if entity.valid_from == date_: + db.delete(entity) + else: + entity.valid_till = date_ - timedelta(days=1) + + # ---------- routes ---------- @@ -98,23 +366,22 @@ 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), start=Decimal(0)), 2) + sale_price = _compute_sale_price(data.items) product = Product(sort_order=data.sort_order) db.add(product) db.flush() - pv = ProductVersion( - product_id=None, - product=product, - name=data.name, - fraction_units=data.units, # per your rule - sale_category_id=data.sale_category.id_, - sale_category=None, - valid_from=date_, - valid_till=None, + db.add( + ProductVersion( + product_id=product.id, + name=data.name, + fraction_units=data.units, + sale_category_id=data.sale_category.id_, + valid_from=date_, + valid_till=None, + ) ) - db.add(pv) sku = StockKeepingUnit( product=product, @@ -125,28 +392,28 @@ def save( 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( + 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), + item_id=it.sku.id_, + quantity=_round_qty(it.quantity), + sale_price=_round_price(it.sale_price), print_in_bill=it.print_in_bill, valid_from=date_, valid_till=None, @@ -158,7 +425,7 @@ def save( @router.put("/{id_}", response_model=None) def update_route( - id_: uuid.UUID, # bundle header SKU id + id_: uuid.UUID, data: schemas.BundleIn, date_: Annotated[date, Depends(effective_date)], user: Annotated[UserToken, Security(get_user, scopes=["products"])], @@ -169,142 +436,24 @@ 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), start=Decimal(0)), 2) - # Load header SKU + active header SkuVersion + menu category + product + active ProductVersion - sv: SkuVersion | None = ( - db.execute( - select(SkuVersion) - .join(StockKeepingUnit, onclause=_sv_onclause(date_)) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=_pv_onclause(date_)) - .join(SkuVersion.menu_category) - .where( - StockKeepingUnit.id == id_, - StockKeepingUnit.is_bundle == True, # noqa: E712 - ) - .options( - contains_eager(SkuVersion.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions), - contains_eager(SkuVersion.menu_category), - ) - ) - .unique() - .scalars() - .one_or_none() - ) + sale_price = _compute_sale_price(data.items) + sv: SkuVersion | None = db.execute(_select_bundle_header(date_, sku_id=id_)).unique().scalars().one_or_none() if sv is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") sku = sv.sku product = sku.product pv = product.versions[0] - pv_changed = pv.name != data.name or pv.sale_category_id != data.sale_category.id_ + # Always-mutable fields (no versioning needed). sku.is_not_available = data.is_not_available sku.sort_order = data.sort_order product.sort_order = data.sort_order - if pv_changed: - if pv.valid_from == date_: - pv.name = data.name - pv.sale_category_id = data.sale_category.id_ - else: - pv.valid_till = date_ - timedelta(days=1) - db.add( - ProductVersion( - product_id=product.id, - product=product, - name=data.name, - fraction_units=pv.fraction_units, - sale_category_id=data.sale_category.id_, - valid_from=date_, - valid_till=None, - ) - ) - sv_changed = ( - sv.units != data.units - or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price - or sv.has_happy_hour != data.has_happy_hour - or sv.menu_category_id != data.menu_category.id_ - ) - if sv_changed: - if sv.valid_from == date_: - sv.units = data.units - sv.sale_price = sale_price - sv.has_happy_hour = data.has_happy_hour - sv.menu_category_id = data.menu_category.id_ - - # # enforce bundle constants - # sv.fraction = FRACTION_1 - # sv.product_yield = YIELD_1 - # sv.cost_price = COST_0 - else: - sv.valid_till = date_ - timedelta(days=1) - db.add( - SkuVersion( - sku_id=sku.id, - units=data.units, - fraction=FRACTION_1, - product_yield=YIELD_1, - cost_price=COST_0, - sale_price=sale_price, - has_happy_hour=data.has_happy_hour, - menu_category_id=data.menu_category.id_, - valid_from=date_, - valid_till=None, - ) - ) - - # ---- Bundle items replace/update - existing = db.execute(select(BundleItemModel).where(BundleItemModel.bundle_id == sku.id)).scalars().all() - existing_by_item = {x.item_id: x for x in existing} - incoming_ids = {x.item_id for x in data.items} - - existing_by_item = {x.item_id: x for x in existing} - - incoming_ids = {x.item_id for x in data.items} - - # delete removed - for ex_d in existing: - if ex_d.item_id not in incoming_ids: - if ex_d.valid_from == date_: - db.delete(ex_d) - else: - ex_d.valid_till = date_ - timedelta(days=1) - - # add/update - for it in data.items: - ex = existing_by_item.get(it.item_id) - if ex is None: - db.add( - BundleItemModel( - bundle_id=sku.id, - item_id=it.item_id, - sale_price=round(it.sale_price, 2), - quantity=round(it.quantity, 5), - print_in_bill=it.print_in_bill, - valid_from=date_, - valid_till=None, - ) - ) - elif ex.valid_from == date_: - ex.sale_price = round(it.sale_price, 2) - ex.quantity = round(it.quantity, 5) - else: - ex.valid_till = date_ - timedelta(days=1) - db.add( - BundleItemModel( - bundle_id=sku.id, - item_id=it.item_id, - sale_price=round(it.sale_price, 2), - quantity=round(it.quantity, 5), - print_in_bill=it.print_in_bill, - valid_from=date_, - valid_till=None, - ) - ) + _update_product_version(db, pv, date_, data) + _update_sku_version(db, sv, date_, data, sale_price) + _sync_bundle_items(db, sku.id, date_, data.items) db.commit() @@ -313,7 +462,7 @@ def update_route( def show_blank( user: Annotated[UserToken, Security(get_user, scopes=["products"])], ) -> schemas.BundleBlank: - return bundle_blank() + return _bundle_blank() @router.get("/list", response_model=list[schemas.Bundle]) @@ -322,133 +471,47 @@ def show_list( user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep, ) -> list[schemas.Bundle]: - 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) - ) + rows: Sequence[SkuVersion] = ( + db.execute(_select_bundle_header(date_).order_by(StockKeepingUnit.sort_order, SkuVersion.units)) .unique() .scalars() .all() ) + if not rows: + return [] - out: list[schemas.Bundle] = [] - for sv in rows: - pv = sv.sku.product.versions[0] - items = ( - db.execute( - select(BundleItemModel) - .join(BundleItemModel.item) - .join(SkuVersion, onclause=sv_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=pv_onclause) - .join(ProductVersion.sale_category) - .where( - BundleItemModel.bundle_id == sv.sku_id, - or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 - or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 - ) - .options( - contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), - contains_eager(BundleItemModel.item) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions) - .contains_eager(ProductVersion.sale_category), - ) - ) - .unique() - .scalars() - .all() - ) - out.append(_bundle_info(pv=pv, sv=sv, items=items)) - return out + # Batch-load all bundle items in a single query to avoid N+1. + all_sku_ids = [sv.sku_id for sv in rows] + all_items: Sequence[BundleItemModel] = db.execute(_select_bundle_items(date_, all_sku_ids)).unique().scalars().all() + items_by_bundle: dict[uuid.UUID, list[BundleItemModel]] = defaultdict(list) + for bi in all_items: + items_by_bundle[bi.bundle_id].append(bi) + + return [_bundle_info(pv=sv.sku.product.versions[0], sv=sv, items=items_by_bundle.get(sv.sku_id, [])) for sv in rows] @router.get("/{id_}", response_model=schemas.Bundle) def show_id( - id_: uuid.UUID, # bundle header SKU id + id_: uuid.UUID, date_: Annotated[date, Depends(effective_date)], user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep, ) -> schemas.Bundle: - 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() - ) - + sv: SkuVersion | None = db.execute(_select_bundle_header(date_, sku_id=id_)).unique().scalar_one_or_none() if sv is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.") - pv = sv.sku.product.versions[0] - - items = ( - db.execute( - select(BundleItemModel) - .join(BundleItemModel.item) - .join(SkuVersion, onclause=sv_onclause) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=pv_onclause) - .join(ProductVersion.sale_category) - .where( - BundleItemModel.bundle_id == sv.sku_id, - or_(BundleItemModel.valid_from == None, BundleItemModel.valid_from <= date_), # noqa: E711 - or_(BundleItemModel.valid_till == None, BundleItemModel.valid_till >= date_), # noqa: E711 - ) - .options( - contains_eager(BundleItemModel.item).contains_eager(StockKeepingUnit.versions), - contains_eager(BundleItemModel.item) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions) - .contains_eager(ProductVersion.sale_category), - ) - ) - .unique() - .scalars() - .all() - ) - return _bundle_info(pv=pv, sv=sv, items=items) + items: Sequence[BundleItemModel] = db.execute(_select_bundle_items(date_, [sv.sku_id])).unique().scalars().all() + return _bundle_info(pv=sv.sku.product.versions[0], sv=sv, items=items) @router.delete("/{id_}", response_model=None) def delete_route( - id_: uuid.UUID, # bundle header SKU id + id_: uuid.UUID, date_: Annotated[date, Depends(effective_date)], user: Annotated[UserToken, Security(get_user, scopes=["products"])], db: SessionDep, ) -> None: - 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") @@ -461,57 +524,49 @@ def delete_route( if billed > 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="The cannot delete this product as it was billed", + detail="Cannot delete this product as it was billed.", ) - sv = ( + # The delete query intentionally omits the menu_category join: it doesn't + # need menu category data and the bundle may not have one after versioning. + sv: SkuVersion | None = ( db.execute( select(SkuVersion) - .join(StockKeepingUnit, onclause=sv_onclause) + .join(StockKeepingUnit, onclause=_sv_onclause(date_)) .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=pv_onclause) + .join(ProductVersion, onclause=_pv_onclause(date_)) .where( StockKeepingUnit.id == id_, StockKeepingUnit.is_bundle == True, # noqa: E712 ) - .options(contains_eager(SkuVersion.sku).contains_eager(StockKeepingUnit.product)) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), + ) ) .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_or_delete(db, sv, date_) + _close_or_delete(db, pv, date_) - # close/delete product version - if pv.valid_from == date_: - db.delete(pv) - else: - pv.valid_till = date_ - timedelta(days=1) - - items = ( + active_items: Sequence[BundleItemModel] = ( 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 + _bundle_active(date_), ) ) .scalars() .all() ) - for bi in items: - if bi.valid_from == date_: - db.delete(bi) - else: - bi.valid_till = date_ - timedelta(days=1) + for bi in active_items: + _close_or_delete(db, bi, date_) db.commit() diff --git a/barker/barker/schemas/bundle.py b/barker/barker/schemas/bundle.py index 2c7c9f6..805706d 100644 --- a/barker/barker/schemas/bundle.py +++ b/barker/barker/schemas/bundle.py @@ -7,13 +7,13 @@ from pydantic import BaseModel, ConfigDict, Field from . import Daf, to_camel from .menu_category import MenuCategoryLink +from .product_query import ProductQuery from .sale_category import SaleCategoryLink class BundleItem(BaseModel): id_: uuid.UUID | None = None - name: Annotated[str, Field(min_length=1)] - item_id: Annotated[uuid.UUID, Field(description="StockKeepingUnit ID of the item")] + sku: ProductQuery sale_price: Annotated[Daf, Field(ge=Decimal(0))] quantity: Annotated[Daf, Field(gt=Decimal(0))] print_in_bill: bool diff --git a/barker/barker/schemas/menu_category.py b/barker/barker/schemas/menu_category.py index 1900ce1..e3357b7 100644 --- a/barker/barker/schemas/menu_category.py +++ b/barker/barker/schemas/menu_category.py @@ -30,5 +30,5 @@ class MenuCategoryLink(BaseModel): id_: uuid.UUID name: str | None = None enabled: bool | None = None - skus: list[ProductLink] + skus: list[ProductLink] | None = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) 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 dd10415..07bb18a 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 @@ -13,7 +13,7 @@ import { ProductService } from '../../product/product.service'; import { BundleItem } from '../bundle'; export interface BundleDetailDialogFormData { - product: BundleItem | ProductQuery | string; + product: ProductQuery | string; quantity: number; salePrice: number; printInBill: boolean; @@ -44,7 +44,7 @@ export class BundleDetailDialogComponent { formModel = linkedSignal({ source: () => this.data.item, computation: (item) => ({ - product: item, + product: item.sku ?? '', quantity: Number(item.quantity ?? 1), salePrice: Number(item.salePrice ?? 0), printInBill: item.printInBill ?? false, @@ -84,8 +84,7 @@ export class BundleDetailDialogComponent { return; } - this.data.item.itemId = product.id ?? ''; - this.data.item.name = product.name ?? ''; + this.data.item.sku = product; this.data.item.quantity = quantity; this.data.item.salePrice = salePrice; this.data.item.printInBill = formValue.printInBill ?? false; 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 70d60ef..f1ccd59 100644 --- a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.html @@ -24,7 +24,7 @@ Menu Category @for (mc of menuCategories(); track mc) { - + {{ mc.name }} } @@ -35,7 +35,7 @@ Sale Category @for (sc of saleCategories(); track sc) { - + {{ sc.name }} } @@ -61,9 +61,9 @@ @for (p of itemProducts(); track p) { @@ -89,11 +89,11 @@
- + Item - {{ row.name }} + {{ row.sku?.name }} 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 bd2b52c..272fefd 100644 --- a/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts +++ b/bookie/src/app/bundles/bundle-detail/bundle-detail.component.ts @@ -34,7 +34,7 @@ export interface BundleDetailFormData { hasHappyHour: boolean; isNotAvailable: boolean; addRow: { - itemId: ProductQuery | string; + sku: ProductQuery | string; quantity: number; salePrice: number; printInBill: boolean; @@ -89,7 +89,7 @@ export class BundleDetailComponent { isNotAvailable: itemVal.isNotAvailable ?? false, items, addRow: { - itemId: '', + sku: '', quantity: 1, salePrice: 0, printInBill: true, @@ -101,7 +101,7 @@ export class BundleDetailComponent { form = form(this.formModel); private itemProductSearch = computed(() => { - const v = this.form.addRow.itemId().value(); + const v = this.form.addRow.sku().value(); return typeof v === 'string' ? v : null; }); @@ -116,7 +116,7 @@ export class BundleDetailComponent { constructor() { effect(() => { - const v = this.form.addRow.itemId().value(); + const v = this.form.addRow.sku().value(); if (typeof v === 'object' && v !== null) { this.formModel.update((f) => ({ ...f, @@ -147,7 +147,7 @@ export class BundleDetailComponent { this.formModel.update((f) => ({ ...f, addRow: { - itemId: '', + sku: '', quantity: 1, salePrice: 0, printInBill: true, @@ -158,7 +158,7 @@ export class BundleDetailComponent { addRow() { const formValue = this.form().value().addRow; if (!formValue) return; - const itemProduct = formValue.itemId as ProductQuery; + const itemProduct = formValue.sku as ProductQuery; if (!itemProduct || typeof itemProduct === 'string') { this.snackBar.open('Please select a product', 'Error'); @@ -178,8 +178,7 @@ export class BundleDetailComponent { } const bi = new BundleItem({ - itemId: itemProduct.id, - name: itemProduct.name ?? '', + sku: itemProduct, quantity, salePrice, printInBill: formValue.printInBill || false, 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 7e97d00..788598e 100644 --- a/bookie/src/app/bundles/bundle-list/bundle-list.component.html +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.html @@ -72,7 +72,9 @@ {{ item.printInBill ? 'visibility' : 'visibility_off' }} -
{{ item.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}
+
+ {{ item.sku?.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 940fad2..361bba6 100644 --- a/bookie/src/app/bundles/bundle-list/bundle-list.component.ts +++ b/bookie/src/app/bundles/bundle-list/bundle-list.component.ts @@ -88,7 +88,7 @@ export class BundleListComponent { ...b, menuCategory: b.menuCategory?.name ?? '', items: (b.items ?? []) - .map((i) => `${i.name}${i.quantity && i.quantity !== 1 ? ` x${i.quantity}` : ''}`) + .map((i) => `${i.sku?.name ?? ''}${i.quantity && i.quantity !== 1 ? ` x${i.quantity}` : ''}`) .join(' | '), })); diff --git a/bookie/src/app/bundles/bundle.ts b/bookie/src/app/bundles/bundle.ts index 6ad3d9b..476f2d1 100644 --- a/bookie/src/app/bundles/bundle.ts +++ b/bookie/src/app/bundles/bundle.ts @@ -3,14 +3,13 @@ import { ProductQuery } from '../core/product-query'; import { SaleCategory } from '../core/sale-category'; export class BundleItem { - sku: ProductQuery | undefined; - name: string; + id?: string; + sku?: ProductQuery; salePrice: number; quantity: number; printInBill: boolean; public constructor(init?: Partial) { - this.name = ''; this.salePrice = 0; this.quantity = 0; this.printInBill = true; diff --git a/bookie/src/app/customers/customer-list/customer-list.component.ts b/bookie/src/app/customers/customer-list/customer-list.component.ts index d05841e..3ac0c26 100644 --- a/bookie/src/app/customers/customer-list/customer-list.component.ts +++ b/bookie/src/app/customers/customer-list/customer-list.component.ts @@ -1,6 +1,6 @@ /* eslint-disable @angular-eslint/no-input-rename */ import { PercentPipe } from '@angular/common'; -import { Component, inject, signal, effect, computed, debounced, afterNextRender, input } from '@angular/core'; +import { Component, inject, linkedSignal, effect, computed, debounced, afterNextRender, input } from '@angular/core'; import { form, FormField } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; @@ -51,8 +51,11 @@ export class CustomerListComponent { sortInput = input('name', { alias: 'f' }); direction = input('asc', { alias: 'd' }); - formModel = signal({ - filter: this.route.snapshot.queryParamMap.get('q') ?? '', + formModel = linkedSignal({ + source: this.q, + computation: (q): CustomerListFormData => ({ + filter: q, + }), }); form = form(this.formModel); diff --git a/bookie/src/app/guest-book/guest-book-list/guest-book-list.component.ts b/bookie/src/app/guest-book/guest-book-list/guest-book-list.component.ts index b03edbf..b3d9e7c 100644 --- a/bookie/src/app/guest-book/guest-book-list/guest-book-list.component.ts +++ b/bookie/src/app/guest-book/guest-book-list/guest-book-list.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, computed, effect, signal } from '@angular/core'; +import { Component, inject, computed, effect, input, linkedSignal } from '@angular/core'; import { form, FormField } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -40,8 +40,21 @@ export class GuestBookListComponent { private snackBar = inject(MatSnackBar); private ser = inject(GuestBookService); - formModel = signal({ - date: this.initialDate(), + date = input(new Date(), { + transform: (v: string | null | undefined): Date => { + if (v) { + const parsed = moment(v, 'DD-MMM-YYYY', true); + if (parsed.isValid()) return parsed.toDate(); + } + return new Date(); + }, + }); + + formModel = linkedSignal({ + source: this.date, + computation: (d): GuestBookListFormData => ({ + date: d, + }), }); form = form(this.formModel); @@ -65,13 +78,4 @@ export class GuestBookListComponent { }); }); } - - private initialDate(): Date { - const qp = this.route.snapshot.queryParamMap.get('date'); - if (qp) { - const parsed = moment(qp, 'DD-MMM-YYYY', true); - if (parsed.isValid()) return parsed.toDate(); - } - return new Date(); - } } diff --git a/bookie/src/app/guest-book/guest-book.service.ts b/bookie/src/app/guest-book/guest-book.service.ts index e12a4dc..a7afd64 100644 --- a/bookie/src/app/guest-book/guest-book.service.ts +++ b/bookie/src/app/guest-book/guest-book.service.ts @@ -19,10 +19,7 @@ export class GuestBookService { private http = inject(HttpClient); private log = inject(ErrorLoggerService); - get( - id: Signal, - type?: Signal, - ): HttpResourceRef { + get(id: Signal, type?: Signal): HttpResourceRef { return httpResource(() => { const idVal = id(); const typeVal = type ? type() : ''; diff --git a/bookie/src/app/sales/choose-customer/choose-customer.component.html b/bookie/src/app/sales/choose-customer/choose-customer.component.html index a01fa30..4598dee 100644 --- a/bookie/src/app/sales/choose-customer/choose-customer.component.html +++ b/bookie/src/app/sales/choose-customer/choose-customer.component.html @@ -27,29 +27,27 @@
Name - +
Address - +
- Print in Bill? + Print in Bill?

- @for (r of item.discounts; track r; let i = $index) { -
- - Discount on {{ r.name }} - - % - -
- } +
    + @for (r of formModel().discounts; track r.id) { + @if (r.discount > 0) { +
  • {{ r.name }} - {{ r.discount | percent: '1.2-2' }}
  • + } + } +
diff --git a/bookie/src/app/sales/choose-customer/choose-customer.component.ts b/bookie/src/app/sales/choose-customer/choose-customer.component.ts index 85a2436..58b71a8 100644 --- a/bookie/src/app/sales/choose-customer/choose-customer.component.ts +++ b/bookie/src/app/sales/choose-customer/choose-customer.component.ts @@ -1,4 +1,5 @@ import { CdkScrollableModule } from '@angular/cdk/scrolling'; +import { PercentPipe } from '@angular/common'; import { Component, inject, computed, debounced, linkedSignal, signal } from '@angular/core'; import { form, FormField } from '@angular/forms/signals'; import { MatAutocompleteSelectedEvent, MatAutocompleteModule } from '@angular/material/autocomplete'; @@ -8,9 +9,9 @@ import { MatOptionModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { round } from 'mathjs'; import { Customer } from '../../core/customer'; +import { CustomerDiscount } from '../../core/customer-discount'; import { CustomerService } from '../../customers/customer.service'; export interface ChooseCustomerFormData { @@ -19,9 +20,7 @@ export interface ChooseCustomerFormData { phone: string; address: string; printInBill: boolean; - discounts: { - discount: number | null; - }[]; + discounts: CustomerDiscount[]; } @Component({ @@ -37,6 +36,7 @@ export interface ChooseCustomerFormData { MatFormFieldModule, MatInputModule, MatOptionModule, + PercentPipe, FormField, ], }) @@ -55,9 +55,7 @@ export class ChooseCustomerComponent { phone: data.phone, address: data.address ?? '', printInBill: data.printInBill, - discounts: data.discounts.map((x) => ({ - discount: x.discount ? x.discount * 100 : null, - })), + discounts: data.discounts ?? [], }), }); @@ -70,7 +68,9 @@ export class ChooseCustomerComponent { debouncedPhone = debounced(this.phoneSignal, 150); customersResource = this.ser.autocomplete(this.debouncedPhone.value); - customers = computed(() => { return this.customersResource.value() ?? [] }); + customers = computed(() => { + return this.customersResource.value() ?? []; + }); save() { const customer = this.getItem(); @@ -92,7 +92,6 @@ export class ChooseCustomerComponent { getItem(): Customer { const formModel = this.formModel(); - const array = formModel.discounts; return new Customer({ id: formModel.id, @@ -100,14 +99,7 @@ export class ChooseCustomerComponent { phone: formModel.phone ?? '', address: formModel.address ?? '', printInBill: formModel.printInBill ?? false, - discounts: formModel.discounts.map((item, index) => { - const array_item = array?.[index]; - return { - ...this.item().discounts?.[index], - discount: - array_item && array_item?.discount ? Math.max(Math.min(round(array_item.discount / 100, 5), 100), 0) : 0, - }; - }), + discounts: formModel.discounts.map((item) => ({ ...item })), }); } } diff --git a/bookie/src/app/sales/discount/customer-discounts.service.ts b/bookie/src/app/sales/discount/customer-discounts.service.ts index 2d5e43e..c5d32f8 100644 --- a/bookie/src/app/sales/discount/customer-discounts.service.ts +++ b/bookie/src/app/sales/discount/customer-discounts.service.ts @@ -12,12 +12,15 @@ export class CustomerDiscountsService { private injector = inject(Injector); list(id: Signal): HttpResourceRef { - return httpResource(() => { - const idVal = id(); - return idVal === undefined ? url : `${url}/${idVal}`; - }, { - injector: this.injector, - }); + return httpResource( + () => { + const idVal = id(); + return idVal === undefined ? url : `${url}/${idVal}`; + }, + { + injector: this.injector, + }, + ); } listForDiscount(): HttpResourceRef<{ name: string; discount: number; discountLimit: number }[] | undefined> {