Compare commits

...
3 Commits
Author SHA1 Message Date
tanshu dcf1ffad98 Version Bump v14.3.0 2026-02-17 09:45:32 +05:30
tanshu 338d9d63d5 Validation: Making Product name unique ignoring the fraction_units.
Making the sku_version.units unique for a product across stock_keeping_units.

Both respecting the validity
2026-02-17 04:13:11 +00:00
tanshu 9b0da9cb65 Fix: Product sale report was aggregating on the product_version_id 2026-02-14 12:20:09 +00:00
11 changed files with 198 additions and 20 deletions
@@ -34,7 +34,7 @@ def upgrade():
) )
op.create_exclude_constraint( op.create_exclude_constraint(
"uq_product_versions_product_id", op.f("uq_product_versions_product_id"),
"product_versions", "product_versions",
(prod.c.product_id, "="), (prod.c.product_id, "="),
(func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"),
@@ -0,0 +1,156 @@
"""index
Revision ID: 8260414066d6
Revises: 5cb65066be86
Create Date: 2026-02-13 06:22:39.926120
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "8260414066d6"
down_revision = "5cb65066be86"
branch_labels = None
depends_on = None
def upgrade():
# 1) Add the column (nullable for backfill)
op.add_column("sku_versions", sa.Column("product_id", sa.UUID(), nullable=True))
# 2) Define lightweight table objects (no autoload; explicit columns only)
sku_versions = sa.Table(
"sku_versions",
sa.MetaData(),
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("sku_id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=True),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_till", sa.Date(), nullable=True),
sa.Column("units", sa.Unicode(), nullable=False),
)
stock_keeping_units = sa.Table(
"stock_keeping_units",
sa.MetaData(),
sa.Column("id", sa.UUID(), primary_key=True),
sa.Column("product_id", sa.UUID(), nullable=False),
)
# 3) Backfill via SQLAlchemy Core UPDATE..SET..(SELECT ...)
product_id_subq = (
sa.select(stock_keeping_units.c.product_id)
.where(stock_keeping_units.c.id == sku_versions.c.sku_id)
.scalar_subquery()
)
backfill_stmt = (
sa.update(sku_versions).values(product_id=product_id_subq).where(sku_versions.c.product_id.is_(None))
)
op.execute(backfill_stmt)
# 5) Enforce NOT NULL at schema level
op.alter_column("sku_versions", "product_id", nullable=False)
# 6) Trigger keeps product_id in sync when sku_id changes
op.execute(
sa.text(
"""
CREATE OR REPLACE FUNCTION sku_versions_set_product_id()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
SELECT s.product_id INTO NEW.product_id
FROM stock_keeping_units s
WHERE s.id = NEW.sku_id;
IF NEW.product_id IS NULL THEN
RAISE EXCEPTION 'Invalid sku_id %, no product found', NEW.sku_id;
END IF;
RETURN NEW;
END;
$$;
"""
)
)
op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;"))
op.execute(
sa.text(
"""
CREATE TRIGGER trg_sku_versions_set_product_id
BEFORE INSERT OR UPDATE OF sku_id
ON sku_versions
FOR EACH ROW
EXECUTE FUNCTION sku_versions_set_product_id();
"""
)
)
# 7) Exclusion constraint: product_id + units must not overlap in time
# daterange(valid_from, valid_till, '[]') overlap operator &&.
sv = sa.table(
"sku_versions",
sa.column("product_id", sa.UUID()),
sa.column("units", sa.UUID()),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
op.create_exclude_constraint(
op.f("uq_sku_versions_product_id_units"),
"sku_versions",
(sv.c.product_id, "="),
(sv.c.units, "="),
(sa.func.daterange(sv.c.valid_from, sv.c.valid_till, sa.text("'[]'")), "&&"),
)
prod = sa.table(
"product_versions",
sa.column("id", sa.UUID()),
sa.column("name", sa.Unicode(length=255)),
sa.column("fraction_units", sa.Unicode(length=255)),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
# Update the exclude constraint on product_versions to only be on the name and drop fraction_units from it
op.drop_constraint("uq_product_versions_name", "product_versions", type_="unique")
op.create_exclude_constraint(
op.f("uq_product_versions_name"),
"product_versions",
(prod.c.name, "="),
(sa.func.daterange(prod.c.valid_from, prod.c.valid_till, sa.text("'[]'")), "&&"),
)
# ### end Alembic commands ###
def downgrade():
op.drop_constraint("uq_sku_versions_product_id_units", "sku_versions", type_="exclude")
# Drop trigger + function
op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;"))
op.execute(sa.text("DROP FUNCTION IF EXISTS sku_versions_set_product_id();"))
# Drop column
op.drop_column("sku_versions", "product_id")
prod = sa.table(
"product_versions",
sa.column("id", sa.UUID()),
sa.column("name", sa.Unicode(length=255)),
sa.column("fraction_units", sa.Unicode(length=255)),
sa.column("valid_from", sa.Date()),
sa.column("valid_till", sa.Date()),
)
op.drop_constraint("uq_product_versions_name", "product_versions", type_="unique")
op.create_exclude_constraint(
"uq_product_versions_name",
"product_versions",
(prod.c.name, "="),
(prod.c.units, "="),
(sa.func.daterange(prod.c.valid_from, prod.c.valid_till, sa.text("'[]'")), "&&"),
)
+1 -1
View File
@@ -1 +1 @@
__version__ = "14.2.1" __version__ = "14.3.0"
+25 -1
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Unicode, Uuid, func, text from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Unicode, Uuid, func, text
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from ..db.base_class import reg from ..db.base_class import reg
@@ -26,6 +26,14 @@ class SkuVersion:
Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()")
) )
sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False)
# DB column is "product_id", but ORM attribute is private: "_product_id"
_product_id: Mapped[uuid.UUID] = mapped_column(
"product_id",
Uuid,
nullable=False,
repr=False, # hides in dataclass repr
init=False, # not part of __init__ signature (dataclass)
)
units: Mapped[str] = mapped_column( units: Mapped[str] = mapped_column(
Unicode, nullable=False Unicode, nullable=False
) # Need to have logic in the application to handle unit uniqueness since we don't have product_id here ) # Need to have logic in the application to handle unit uniqueness since we don't have product_id here
@@ -56,8 +64,24 @@ class SkuVersion:
(sku_id, "="), (sku_id, "="),
(func.daterange(valid_from, valid_till, text("'[]'")), "&&"), (func.daterange(valid_from, valid_till, text("'[]'")), "&&"),
), ),
# product-level uniqueness per time range
postgresql.ExcludeConstraint(
(_product_id, "="),
(units, "="),
(func.daterange(valid_from, valid_till, text("'[]'")), "&&"),
name="uq_sku_versions_product_units_time",
using="gist",
),
) )
@validates("_product_id")
def _prevent_writes_to_product_id(self, key, value):
"""
This column is integrity-only. We never accept app-side writes.
DB trigger sets it.
"""
raise ValueError("product_id is managed by the database and must not be set in application code.")
def __init__( def __init__(
self, self,
units: str = "", units: str = "",
@@ -53,13 +53,11 @@ def product_sale_report(
start_date: date, finish_date: date, id_: uuid.UUID | None, db: Session start_date: date, finish_date: date, id_: uuid.UUID | None, db: Session
) -> list[ProductSaleReportItem]: ) -> list[ProductSaleReportItem]:
day = func.cast( day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day") ).label("day")
product_version_onclause = _pv_onclause(day)
sku_version_onclause = _sv_onclause(day)
query = ( query = (
select( select(
ProductVersion.id, StockKeepingUnit.id,
ProductVersion.name, ProductVersion.name,
SkuVersion.units, SkuVersion.units,
Voucher.voucher_type, Voucher.voucher_type,
@@ -69,10 +67,10 @@ def product_sale_report(
.join(Inventory.kot) .join(Inventory.kot)
.join(Kot.voucher) .join(Kot.voucher)
.join(Inventory.sku) .join(Inventory.sku)
.join(SkuVersion, onclause=sku_version_onclause) .join(SkuVersion, onclause=_sv_onclause(day))
.join(SkuVersion.menu_category) .join(SkuVersion.menu_category)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause) .join(ProductVersion, onclause=_pv_onclause(day))
.join(ProductVersion.sale_category) .join(ProductVersion.sale_category)
.join(Voucher.food_table) .join(Voucher.food_table)
.where( .where(
@@ -85,7 +83,7 @@ def product_sale_report(
query = query.group_by( query = query.group_by(
SaleCategory.name, SaleCategory.name,
MenuCategory.name, MenuCategory.name,
ProductVersion.id, StockKeepingUnit.id,
ProductVersion.name, ProductVersion.name,
SkuVersion.units, SkuVersion.units,
Voucher.voucher_type, Voucher.voucher_type,
@@ -93,14 +91,14 @@ def product_sale_report(
).order_by(SaleCategory.name, MenuCategory.name, ProductVersion.name, SkuVersion.units) ).order_by(SaleCategory.name, MenuCategory.name, ProductVersion.name, SkuVersion.units)
list_ = db.execute(query).all() list_ = db.execute(query).all()
info: list[ProductSaleReportItem] = [] info: list[ProductSaleReportItem] = []
for product_version_id, name, units, v_type, hh, quantity in list_: for sku_id, name, units, v_type, hh, quantity in list_:
type_ = VoucherType(v_type).name type_ = VoucherType(v_type).name
old = next((i for i in info if i.product_version_id == product_version_id and i.is_happy_hour == hh), None) old = next((i for i in info if i.sku_id == sku_id and i.is_happy_hour == hh), None)
if old: if old:
old[type_] = old[type_] + quantity old[type_] = old[type_] + quantity
else: else:
item = ProductSaleReportItem( item = ProductSaleReportItem(
product_version_id=product_version_id, name=f"{'H H ' if hh else ''}{name} ({units})", is_happy_hour=hh sku_id=sku_id, name=f"{'H H ' if hh else ''}{name} ({units})", is_happy_hour=hh
) )
item[type_] = quantity item[type_] = quantity
info.append(item) info.append(item)
+1 -1
View File
@@ -60,7 +60,7 @@ def get_sale_report(
def get_sale(start_date: date, finish_date: date, id_: uuid.UUID | None, db: Session) -> list[SaleReportItem]: def get_sale(start_date: date, finish_date: date, id_: uuid.UUID | None, db: Session) -> list[SaleReportItem]:
day = func.cast( day = func.cast(
Voucher.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date Kot.date + timedelta(minutes=settings.TIMEZONE_OFFSET_MINUTES - settings.NEW_DAY_OFFSET_MINUTES), Date
).label("day") ).label("day")
product_version_onclause = _pv_onclause(day) product_version_onclause = _pv_onclause(day)
query = ( query = (
+2 -2
View File
@@ -19,7 +19,7 @@ from .user import UserLink
class ProductSaleReportItem(BaseModel): class ProductSaleReportItem(BaseModel):
product_version_id: uuid.UUID sku_id: uuid.UUID
name: str name: str
is_happy_hour: bool is_happy_hour: bool
@@ -40,7 +40,7 @@ class ProductSaleReportItem(BaseModel):
@model_serializer(mode="plain") @model_serializer(mode="plain")
def custom_dump(self) -> dict: # type: ignore def custom_dump(self) -> dict: # type: ignore
base = { base = {
"productVersionId": str(self.product_version_id), "skuId": str(self.sku_id),
"name": self.name, "name": self.name,
"isHappyHour": self.is_happy_hour, "isHappyHour": self.is_happy_hour,
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "barker" name = "barker"
version = "14.2.1" version = "14.3.0"
description = "Point of Sale for a restaurant" description = "Point of Sale for a restaurant"
authors = ["tanshu <git@tanshu.com>"] authors = ["tanshu <git@tanshu.com>"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bookie", "name": "bookie",
"version": "14.2.1", "version": "14.3.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
+1 -1
View File
@@ -1,7 +1,7 @@
export const environment = { export const environment = {
production: true, production: true,
ACCESS_TOKEN_REFRESH_MINUTES: 10, // refresh token 10 minutes before expiry ACCESS_TOKEN_REFRESH_MINUTES: 10, // refresh token 10 minutes before expiry
version: '14.2.1', version: '14.3.0',
}; };
export const dateFormat = { export const dateFormat = {
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "frank" name = "frank"
version = "14.2.1" version = "14.3.0"
description = "Point of Sale for a restaurant" description = "Point of Sale for a restaurant"
authors = ["tanshu <git@tanshu.com>"] authors = ["tanshu <git@tanshu.com>"]