fds
This commit is contained in:
Vendored
+12
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+22
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' | '),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user