This commit is contained in:
2026-08-27 11:38:38 +00:00
parent 311464e593
commit ff537edb06
19 changed files with 456 additions and 393 deletions
+12 -1
View File
@@ -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)
+1 -1
View File
@@ -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
-21
View File
@@ -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
}
}
+22
View File
@@ -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"
}
}
+355 -300
View File
@@ -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,
db.add(
ProductVersion(
product_id=product.id,
name=data.name,
fraction_units=data.units, # per your rule
fraction_units=data.units,
sale_category_id=data.sale_category.id_,
sale_category=None,
valid_from=date_,
valid_till=None,
)
db.add(pv)
)
sku = StockKeepingUnit(
product=product,
@@ -125,123 +392,6 @@ 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(sv)
# bundle items
for it in data.items:
db.add(
BundleItemModel(
bundle_id=sku.id,
item_id=it.item_id,
quantity=round(it.quantity, 5),
sale_price=round(it.sale_price, 2),
print_in_bill=it.print_in_bill,
valid_from=date_,
valid_till=None,
)
)
db.commit()
@router.put("/{id_}", response_model=None)
def update_route(
id_: uuid.UUID, # bundle header SKU id
data: schemas.BundleIn,
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
sale_price = round(sum((round(it.sale_price, 2) * round(it.quantity, 5) for it in data.items), start=Decimal(0)), 2)
# Load header SKU + active header SkuVersion + menu category + product + active ProductVersion
sv: SkuVersion | None = (
db.execute(
select(SkuVersion)
.join(StockKeepingUnit, onclause=_sv_onclause(date_))
.join(StockKeepingUnit.product)
.join(ProductVersion, onclause=_pv_onclause(date_))
.join(SkuVersion.menu_category)
.where(
StockKeepingUnit.id == id_,
StockKeepingUnit.is_bundle == True, # noqa: E712
)
.options(
contains_eager(SkuVersion.sku)
.contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
contains_eager(SkuVersion.menu_category),
)
)
.unique()
.scalars()
.one_or_none()
)
if sv is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bundle not found.")
sku = sv.sku
product = sku.product
pv = product.versions[0]
pv_changed = pv.name != data.name or pv.sale_category_id != data.sale_category.id_
sku.is_not_available = data.is_not_available
sku.sort_order = data.sort_order
product.sort_order = data.sort_order
if pv_changed:
if pv.valid_from == date_:
pv.name = data.name
pv.sale_category_id = data.sale_category.id_
else:
pv.valid_till = date_ - timedelta(days=1)
db.add(
ProductVersion(
product_id=product.id,
product=product,
name=data.name,
fraction_units=pv.fraction_units,
sale_category_id=data.sale_category.id_,
valid_from=date_,
valid_till=None,
)
)
sv_changed = (
sv.units != data.units
or Decimal(sv.sale_price).quantize(Decimal("0.01")) != sale_price
or sv.has_happy_hour != data.has_happy_hour
or sv.menu_category_id != data.menu_category.id_
)
if sv_changed:
if sv.valid_from == date_:
sv.units = data.units
sv.sale_price = sale_price
sv.has_happy_hour = data.has_happy_hour
sv.menu_category_id = data.menu_category.id_
# # enforce bundle constants
# sv.fraction = FRACTION_1
# sv.product_yield = YIELD_1
# sv.cost_price = COST_0
else:
sv.valid_till = date_ - timedelta(days=1)
db.add(
SkuVersion(
sku_id=sku.id,
@@ -257,49 +407,13 @@ def update_route(
)
)
# ---- 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),
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,
@@ -309,11 +423,46 @@ def update_route(
db.commit()
@router.put("/{id_}", response_model=None)
def update_route(
id_: uuid.UUID,
data: schemas.BundleIn,
date_: Annotated[date, Depends(effective_date)],
user: Annotated[UserToken, Security(get_user, scopes=["products"])],
db: SessionDep,
) -> None:
if not data.items:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough bundle items.",
)
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]
# 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
_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()
@router.get("", response_model=schemas.BundleBlank)
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()
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
@@ -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<BundleItem, BundleDetailDialogFormData>({
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;
@@ -24,7 +24,7 @@
<mat-label>Menu Category</mat-label>
<mat-select [formField]="form.menuCategory">
@for (mc of menuCategories(); track mc) {
<mat-option [value]="mc.id">
<mat-option [value]="mc">
{{ mc.name }}
</mat-option>
}
@@ -35,7 +35,7 @@
<mat-label>Sale Category</mat-label>
<mat-select [formField]="form.saleCategory">
@for (sc of saleCategories(); track sc) {
<mat-option [value]="sc.id">
<mat-option [value]="sc">
{{ sc.name }}
</mat-option>
}
@@ -61,9 +61,9 @@
<input
type="text"
matInput
[formField]="form.addRow.itemId"
[formField]="form.addRow.sku"
[matAutocomplete]="autoItem"
[value]="displayFn(formModel().addRow.itemId)"
[value]="displayFn(formModel().addRow.sku)"
/>
<mat-autocomplete #autoItem="matAutocomplete" [displayWith]="displayFn">
@for (p of itemProducts(); track p) {
@@ -89,11 +89,11 @@
<div class="row-container wrapped">
<mat-table [dataSource]="formModel().items" aria-label="Bundle Items" class="flex-auto">
<!-- Name Column (name is "name (units)") -->
<!-- Name Column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Item</mat-header-cell>
<mat-cell *matCellDef="let row">
{{ row.name }}
{{ row.sku?.name }}
</mat-cell>
</ng-container>
@@ -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,
@@ -72,7 +72,9 @@
<mat-icon>
{{ item.printInBill ? 'visibility' : 'visibility_off' }}
</mat-icon>
<div class="item-name">{{ item.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}</div>
<div class="item-name">
{{ item.sku?.name }} x {{ item.quantity }} @ {{ item.salePrice | currency: 'INR' }}
</div>
}
</div>
</mat-cell>
@@ -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(' | '),
}));
+2 -3
View File
@@ -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<BundleItem>) {
this.name = '';
this.salePrice = 0;
this.quantity = 0;
this.printInBill = true;
@@ -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<string>('name', { alias: 'f' });
direction = input<string>('asc', { alias: 'd' });
formModel = signal<CustomerListFormData>({
filter: this.route.snapshot.queryParamMap.get('q') ?? '',
formModel = linkedSignal({
source: this.q,
computation: (q): CustomerListFormData => ({
filter: q,
}),
});
form = form(this.formModel);
@@ -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<GuestBookListFormData>({
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();
}
}
@@ -19,10 +19,7 @@ export class GuestBookService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
get(
id: Signal<string | null>,
type?: Signal<GuestBookType | string>,
): HttpResourceRef<GuestBook | undefined> {
get(id: Signal<string | null>, type?: Signal<GuestBookType | string>): HttpResourceRef<GuestBook | undefined> {
return httpResource<GuestBook>(() => {
const idVal = id();
const typeVal = type ? type() : '';
@@ -27,29 +27,27 @@
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Name</mat-label>
<input matInput [formField]="form.name" />
<input matInput [value]="formModel().name" readonly />
</mat-form-field>
</div>
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Address</mat-label>
<textarea matInput [formField]="form.address"> </textarea>
<textarea matInput [value]="formModel().address" readonly> </textarea>
</mat-form-field>
</div>
<div class="row-container">
<mat-checkbox [formField]="form.printInBill">Print in Bill?</mat-checkbox>
<mat-checkbox [checked]="formModel().printInBill" [disabled]="true">Print in Bill?</mat-checkbox>
</div>
<p></p>
<div class="discounts">
@for (r of item.discounts; track r; let i = $index) {
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Discount on {{ r.name }}</mat-label>
<input matInput [formField]="form.discounts[i].discount" />
<span matTextSuffix>%</span>
</mat-form-field>
</div>
<ul>
@for (r of formModel().discounts; track r.id) {
@if (r.discount > 0) {
<li>{{ r.name }} - {{ r.discount | percent: '1.2-2' }}</li>
}
}
</ul>
</div>
</form>
</mat-dialog-content>
@@ -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 })),
});
}
}
@@ -12,12 +12,15 @@ export class CustomerDiscountsService {
private injector = inject(Injector);
list(id: Signal<string | undefined>): HttpResourceRef<DiscountItem[] | undefined> {
return httpResource<DiscountItem[]>(() => {
return httpResource<DiscountItem[]>(
() => {
const idVal = id();
return idVal === undefined ? url : `${url}/${idVal}`;
}, {
},
{
injector: this.injector,
});
},
);
}
listForDiscount(): HttpResourceRef<{ name: string; discount: number; discountLimit: number }[] | undefined> {