diff --git a/.env b/.env index d56c4c0e..be1525d7 100644 --- a/.env +++ b/.env @@ -1,22 +1,22 @@ -TITLE="Brewman" +TITLE=Hops n Grains HOST=0.0.0.0 -PORT=80 -LOG_LEVEL=WARN +PORT=9998 +LOG_LEVEL=WARNING DEBUG=true -SQLALCHEMY_DATABASE_URI= +SQLALCHEMY_DATABASE_URI=postgresql+psycopg://postgres:123456@10.1.1.1:5432/hops MODULE_NAME=brewman.main PROJECT_NAME=brewman -POSTGRES_SERVER= -POSTGRES_USER= -POSTGRES_PASSWORD= -POSTGRES_DB= +POSTGRES_SERVER=localhost +POSTGRES_USER=postgres +POSTGRES_PASSWORD=123456 +POSTGRES_DB=hops -# openssl rand -hex 32 -SECRET_KEY= +PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAX5qCMOidCSToKo2oe6eNii+Jg9qKiQDz2+JD/r3gQAs=\n-----END PUBLIC KEY----- +PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHlhlEP7acCnOWq6VUWJN/q2NU2Mwo7eEtMo1em6luy7\n-----END PRIVATE KEY----- # openssl rand -hex 5 -MIDDLEWARE_SECRET_KEY= -ALGORITHM=HS256 +MIDDLEWARE_SECRET_KEY=c982367648 +ALGORITHM=EdDSA JWT_TOKEN_EXPIRE_MINUTES=30 ALEMBIC_LOG_LEVEL=INFO -ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARN +ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARNING diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6d476603 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +FROM node:latest AS base + +# Install dependencies only when needed +FROM base AS deps +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY overlord/package.json overlord/yarn.lock* overlord/package-lock.json* overlord/pnpm-lock.yaml* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + +FROM base as builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY /overlord ./ + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +FROM python:3.13 AS runner +LABEL maintainer="Amritanshu " + +RUN apt-get update && \ + apt-get install -y locales && \ + sed --in-place --expression='s/# en_IN UTF-8/en_IN UTF-8/' /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales + +ENV LANG en_IN +ENV LC_ALL en_IN + +# Install Poetry +RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python - && \ + cd /usr/local/bin && \ + ln -s /opt/poetry/bin/poetry && \ + poetry config virtualenvs.create false + +WORKDIR /app + +COPY brewman/pyproject.toml /app/pyproject.toml +# Allow installing dev dependencies to run tests +ARG INSTALL_DEV=false +RUN bash -c "if [ $INSTALL_DEV == 'true' ] ; then poetry install --no-root ; else poetry install --no-root --only main ; fi" + +COPY /brewman ./ +COPY --from=builder /frontend/browser /app/frontend + +ENV PYTHONPATH=/app +EXPOSE 80 +VOLUME /frontend + +RUN chmod 777 /app/docker-entrypoint.sh \ + && ln -s /app/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh \ + && ln -s /app/docker-entrypoint.sh / +ENTRYPOINT ["docker-entrypoint.sh"] + +CMD ["poetry", "run", "/app/run.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..456358ba --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: build-production +build-production: ## Build the production docker image. + @docker buildx build \ + --platform linux/amd64,linux/arm64/v8 \ + --tag registry.tanshu.com/brewman:latest \ + --push \ + git@git.tanshu.com:tanshu/brewman.git diff --git a/README.md b/README.md index 60d67b2b..1c83806f 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,27 @@ rm -rf `poetry env info -p` ```sh poetry install ``` + + +### Setup python environment +```sh +# Install pyenv to manage python versions +curl https://pyenv.run | bash + +# Install pyenv dependencies to build python (Optional) +sudo apt update; sudo apt install make build-essential libssl-dev zlib1g-dev \ +libbz2-dev libreadline-dev libsqlite3-dev curl git \ +libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev + +# Install python 3.13 +pyenv install 3.13 + +# Use python in the ~/programming/brewman/brewman directory +cd ~/programming/brewman/brewman +pyenv local 3.13 + +# Tell Poetry to use the pyenv Python +poetry env use python + +# Or this +eval $(poetry env activate) \ No newline at end of file diff --git a/brewman/alembic/versions/52b98cebf96d_product_versions.py b/brewman/alembic/versions/52b98cebf96d_product_versions.py new file mode 100644 index 00000000..7db681dd --- /dev/null +++ b/brewman/alembic/versions/52b98cebf96d_product_versions.py @@ -0,0 +1,341 @@ +"""product versions + +Revision ID: 52b98cebf96d +Revises: 82e5c8d18382 +Create Date: 2025-06-08 04:16:11.152656 + +""" + +import sqlalchemy as sa + +from sqlalchemy import column, func, table, text +from sqlalchemy.dialects import postgresql + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "52b98cebf96d" +down_revision = "82e5c8d18382" +branch_labels = None +depends_on = None + + +def upgrade(): + # 1. Create product_versions table + product_versions_schema() + # 2. Create sku_versions table + sku_versions_schema() + # 3. Migrate data from products to product_versions + product_versions_data() + # 4. Migrate data from stock_keeping_units to sku_versions + sku_versions_data() + + +def product_versions_schema(): + op.create_table( + "product_versions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, default=sa.text("gen_random_uuid()")), + sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("products.id"), nullable=False), + sa.Column("handle", sa.Unicode(), nullable=False), + sa.Column("name", sa.Unicode(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("fraction_units", sa.Unicode(), nullable=False), + sa.Column( + "product_group_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("product_groups.id"), nullable=False + ), + sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=False), + sa.Column("is_purchased", sa.Boolean(), nullable=False), + sa.Column("is_sold", sa.Boolean(), nullable=False), + sa.Column("allergen", sa.Text(), nullable=False), + sa.Column("protein", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("carbohydrate", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("total_sugar", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("added_sugar", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("total_fat", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("saturated_fat", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("trans_fat", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("cholestrol", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("sodium", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("msnf", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("other_solids", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("total_solids", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("water", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_till", sa.Date(), nullable=True), + ) + prod = table( + "product_versions", + column("id", postgresql.UUID(as_uuid=True)), + column("product_id", postgresql.UUID(as_uuid=True)), + column("name", sa.Unicode()), + column("handle", sa.Unicode()), + column("valid_from", sa.Date()), + column("valid_till", sa.Date()), + ) + # Add exclusion constraints to product_versions + op.create_exclude_constraint( + "uq_product_versions_name", + "product_versions", + (prod.c.name, "="), + (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), + ) + # Add exclusion constraints to product_versions + op.create_exclude_constraint( + "uq_product_versions_handle", + "product_versions", + (prod.c.handle, "="), + (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), + ) + + # Add exclusion constraints to product_versions + op.create_exclude_constraint( + "uq_product_versions_product_id", + "product_versions", + (prod.c.product_id, "="), + (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), + ) + + +def product_versions_data(): + products = sa.table( + "products", + sa.column("id", sa.String), # use UUID type if available + sa.column("code", sa.Integer), + sa.column("name", sa.String), + sa.column("description", sa.Text), + sa.column("fraction_units", sa.String), + sa.column("product_group_id", sa.String), + sa.column("account_id", sa.String), + sa.column("is_active", sa.Boolean), + sa.column("is_purchased", sa.Boolean), + sa.column("is_sold", sa.Boolean), + sa.column("allergen", sa.Text), + sa.column("protein", sa.Numeric(15, 5)), + sa.column("carbohydrate", sa.Numeric(15, 5)), + sa.column("total_sugar", sa.Numeric(15, 5)), + sa.column("added_sugar", sa.Numeric(15, 5)), + sa.column("total_fat", sa.Numeric(15, 5)), + sa.column("saturated_fat", sa.Numeric(15, 5)), + sa.column("trans_fat", sa.Numeric(15, 5)), + sa.column("cholestrol", sa.Numeric(15, 5)), + sa.column("sodium", sa.Numeric(15, 5)), + sa.column("msnf", sa.Numeric(15, 5)), + sa.column("other_solids", sa.Numeric(15, 5)), + sa.column("total_solids", sa.Numeric(15, 5)), + sa.column("water", sa.Numeric(15, 5)), + ) + + product_versions = sa.table( + "product_versions", + sa.column("id", sa.String), # use UUID type if available + sa.column("product_id", sa.String), + sa.column("handle", sa.Integer), + sa.column("name", sa.String), + sa.column("description", sa.Text), + sa.column("fraction_units", sa.String), + sa.column("product_group_id", sa.String), + sa.column("account_id", sa.String), + sa.column("is_purchased", sa.Boolean), + sa.column("is_sold", sa.Boolean), + sa.column("allergen", sa.Text), + sa.column("protein", sa.Numeric(15, 5)), + sa.column("carbohydrate", sa.Numeric(15, 5)), + sa.column("total_sugar", sa.Numeric(15, 5)), + sa.column("added_sugar", sa.Numeric(15, 5)), + sa.column("total_fat", sa.Numeric(15, 5)), + sa.column("saturated_fat", sa.Numeric(15, 5)), + sa.column("trans_fat", sa.Numeric(15, 5)), + sa.column("cholestrol", sa.Numeric(15, 5)), + sa.column("sodium", sa.Numeric(15, 5)), + sa.column("msnf", sa.Numeric(15, 5)), + sa.column("other_solids", sa.Numeric(15, 5)), + sa.column("total_solids", sa.Numeric(15, 5)), + sa.column("water", sa.Numeric(15, 5)), + sa.column("valid_from", sa.Date()), + sa.column("valid_till", sa.Date()), + ) + + insert_stmt = product_versions.insert().from_select( + [ + "id", + "product_id", + "handle", + "name", + "description", + "fraction_units", + "product_group_id", + "account_id", + "is_purchased", + "is_sold", + "allergen", + "protein", + "carbohydrate", + "total_sugar", + "added_sugar", + "total_fat", + "saturated_fat", + "trans_fat", + "cholestrol", + "sodium", + "msnf", + "other_solids", + "total_solids", + "water", + "valid_from", + "valid_till", + ], + sa.select( + sa.func.gen_random_uuid(), # id + products.c.id, # product_id + products.c.code, # handle + products.c.name, + products.c.description, + products.c.fraction_units, + products.c.product_group_id, + products.c.account_id, + products.c.is_purchased, + products.c.is_sold, + products.c.allergen, + products.c.protein, + products.c.carbohydrate, + products.c.total_sugar, + products.c.added_sugar, + products.c.total_fat, + products.c.saturated_fat, + products.c.trans_fat, + products.c.cholestrol, + products.c.sodium, + products.c.msnf, + products.c.other_solids, + products.c.total_solids, + products.c.water, + sa.null(), # valid_from + sa.case( + (products.c.is_active == sa.text("False"), sa.text("current_date - interval '1 day'")), else_=sa.null() + ), # valid_till + ), + ) + op.execute(insert_stmt) + + # 4. Drop migrated columns from products + columns_to_drop = [ + "code", + "name", + "description", + "fraction_units", + "product_group_id", + "account_id", + "is_active", + "is_purchased", + "is_sold", + "allergen", + "protein", + "carbohydrate", + "total_sugar", + "added_sugar", + "total_fat", + "saturated_fat", + "trans_fat", + "cholestrol", + "sodium", + "msnf", + "other_solids", + "total_solids", + "water", + ] + for col in columns_to_drop: + op.drop_column("products", col) + + +def sku_versions_schema(): + op.create_table( + "sku_versions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("sku_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("stock_keeping_units.id"), nullable=False), + sa.Column("units", sa.Unicode(), nullable=False), + sa.Column("fraction", sa.Numeric(15, 5), nullable=False), + sa.Column("product_yield", sa.Numeric(15, 5), nullable=False), + sa.Column("cost_price", sa.Numeric(15, 2), nullable=False), + sa.Column("sale_price", sa.Numeric(15, 2), nullable=False), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_till", sa.Date(), nullable=True), + ) + + sku = table( + "sku_versions", + column("id", postgresql.UUID(as_uuid=True)), + column("sku_id", postgresql.UUID(as_uuid=True)), + column("units", sa.Unicode()), + column("handle", sa.Unicode()), + column("valid_from", sa.Date()), + column("valid_till", sa.Date()), + ) + # Add exclusion constraints to product_versions + op.create_exclude_constraint( + "uq_sku_versions_sku_id_units", + "sku_versions", + (sku.c.sku_id, "="), + (sku.c.units, "="), + (func.daterange(sku.c.valid_from, sku.c.valid_till, text("'[]'")), "&&"), + ) + + +def sku_versions_data(): + stock_keeping_units = sa.table( + "stock_keeping_units", + sa.column("id", postgresql.UUID(as_uuid=True)), + sa.column("product_id", postgresql.UUID(as_uuid=True)), + sa.column("units", sa.Unicode), + sa.column("fraction", sa.Numeric(15, 5)), + sa.column("product_yield", sa.Numeric(15, 5)), + sa.column("cost_price", sa.Numeric(15, 2)), + sa.column("sale_price", sa.Numeric(15, 2)), + ) + + sku_versions = sa.table( + "sku_versions", + sa.column("id", postgresql.UUID(as_uuid=True)), + sa.column("sku_id", postgresql.UUID(as_uuid=True)), + sa.column("units", sa.Unicode), + sa.column("fraction", sa.Numeric(15, 5)), + sa.column("product_yield", sa.Numeric(15, 5)), + sa.column("cost_price", sa.Numeric(15, 2)), + sa.column("sale_price", sa.Numeric(15, 2)), + sa.column("valid_from", sa.Date), + sa.column("valid_till", sa.Date), + ) + + insert_stmt = sku_versions.insert().from_select( + [ + "id", + "sku_id", + "units", + "fraction", + "product_yield", + "cost_price", + "sale_price", + ], + sa.select( + sa.func.gen_random_uuid(), + stock_keeping_units.c.id, # old SKU id is new sku_id + stock_keeping_units.c.units, + stock_keeping_units.c.fraction, + stock_keeping_units.c.product_yield, + stock_keeping_units.c.cost_price, + stock_keeping_units.c.sale_price, + ), + ) + op.execute(insert_stmt) + + # 4. Drop versioned columns from stock_keeping_units + for col in ["units", "fraction", "product_yield", "cost_price", "sale_price"]: + op.drop_column("stock_keeping_units", col) + + +def downgrade(): + # Reverse the above steps: not implemented for brevity, but you would + # 1. Add columns back to products, + # 2. Move data back from product_versions, + # 3. Drop new columns/constraints, drop product_versions table. + raise NotImplementedError("Downgrade not implemented") diff --git a/brewman/brewman/core/config.py b/brewman/brewman/core/config.py index ea4f71ee..7870d698 100644 --- a/brewman/brewman/core/config.py +++ b/brewman/brewman/core/config.py @@ -1,14 +1,16 @@ import secrets from dotenv import load_dotenv -from pydantic import PostgresDsn, model_validator +from pydantic import PostgresDsn, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): # openssl rand -hex 32 TITLE: str = "" - SECRET_KEY: str = secrets.token_urlsafe(32) + PUBLIC_KEY: str = secrets.token_urlsafe(32) + # openssl rand -hex 32 + PRIVATE_KEY: str = secrets.token_urlsafe(32) MIDDLEWARE_SECRET_KEY: str = secrets.token_urlsafe(5) ALGORITHM: str = "HS256" JWT_TOKEN_EXPIRE_MINUTES: int = 30 @@ -22,6 +24,18 @@ class Settings(BaseSettings): POSTGRES_DB: str = "" SQLALCHEMY_DATABASE_URI: str | None = None + @field_validator("PRIVATE_KEY", mode="before") + def convert_private_key_newlines(cls, v): + if isinstance(v, str): + return v.replace("\\n", "\n") + return v + + @field_validator("PUBLIC_KEY", mode="before") + def convert_public_key_newlines(cls, v): + if isinstance(v, str): + return v.replace("\\n", "\n") + return v + @model_validator(mode="after") def assemble_db_connection(self) -> "Settings": if isinstance(self.SQLALCHEMY_DATABASE_URI, str): diff --git a/brewman/brewman/core/security.py b/brewman/brewman/core/security.py index 6a999aaa..f2417bf6 100644 --- a/brewman/brewman/core/security.py +++ b/brewman/brewman/core/security.py @@ -3,10 +3,10 @@ import uuid from datetime import UTC, datetime, timedelta from typing import Any +import jwt + from fastapi import Depends, HTTPException, Security, status from fastapi.security import OAuth2PasswordBearer, SecurityScopes -from jose import jwt -from jose.exceptions import ExpiredSignatureError from jwt import PyJWTError from pydantic import BaseModel, ValidationError from sqlalchemy import select @@ -37,7 +37,7 @@ def create_access_token(*, data: dict[str, Any], expires_delta: timedelta | None to_encode = data.copy() expire = datetime.now(UTC).replace(tzinfo=None) + (expires_delta or timedelta(minutes=15)) to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + encoded_jwt = jwt.encode(to_encode, settings.PRIVATE_KEY, algorithm=settings.ALGORITHM) return encoded_jwt @@ -80,13 +80,13 @@ async def get_current_user( headers={"WWW-Authenticate": authenticate_value}, ) try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + payload = jwt.decode(token, settings.PUBLIC_KEY, algorithms=[settings.ALGORITHM]) username: str = payload.get("sub") or "" if username is None: raise credentials_exception token_scopes = payload.get("scopes", []) token_data = TokenData(scopes=token_scopes, username=username) - except (PyJWTError, ValidationError, ExpiredSignatureError): + except (PyJWTError, ValidationError): raise credentials_exception user = get_user( username=token_data.username, diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index 96011167..5963a9e6 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -23,6 +23,7 @@ from ..models.permission import Permission # noqa: F401 from ..models.price import Price # noqa: F401 from ..models.product import Product # noqa: F401 from ..models.product_group import ProductGroup # noqa: F401 +from ..models.product_version import ProductVersion # noqa: F401 from ..models.rate_contract import RateContract # noqa: F401 from ..models.rate_contract_item import RateContractItem # noqa: F401 from ..models.recipe import Recipe # noqa: F401 diff --git a/brewman/brewman/models/account.py b/brewman/brewman/models/account.py index 2e891f51..fb7b5d8d 100644 --- a/brewman/brewman/models/account.py +++ b/brewman/brewman/models/account.py @@ -1,25 +1,15 @@ +from __future__ import annotations + import uuid -from typing import TYPE_CHECKING - -from sqlalchemy.orm import Mapped, relationship - from ..db.base_class import reg from .account_base import AccountBase -if TYPE_CHECKING: - from .product import Product - - @reg.mapped_as_dataclass(unsafe_hash=True) class Account(AccountBase): __mapper_args__ = {"polymorphic_identity": ""} # type: ignore - products: Mapped[list["Product"]] = relationship( - "Product", primaryjoin="Account.id==Product.account_id", back_populates="account" - ) - def __init__( self, name: str, diff --git a/brewman/brewman/models/account_base.py b/brewman/brewman/models/account_base.py index 2baf0ef6..f2d93167 100644 --- a/brewman/brewman/models/account_base.py +++ b/brewman/brewman/models/account_base.py @@ -13,7 +13,7 @@ from .account_type import AccountType if TYPE_CHECKING: from .cost_centre import CostCentre from .journal import Journal - from .product import Product + from .product_version import ProductVersion from .rate_contract import RateContract @@ -42,7 +42,7 @@ class AccountBase: journals: Mapped[list["Journal"]] = relationship("Journal", back_populates="account") cost_centre: Mapped["CostCentre"] = relationship("CostCentre", back_populates="accounts") - products: Mapped[list["Product"]] = relationship("Product", back_populates="account") + products: Mapped[list["ProductVersion"]] = relationship("ProductVersion", back_populates="account") rate_contracts: Mapped[list["RateContract"]] = relationship("RateContract", back_populates="vendor") diff --git a/brewman/brewman/models/mozimo_order_sheet.py b/brewman/brewman/models/mozimo_order_sheet.py new file mode 100644 index 00000000..657b3ec0 --- /dev/null +++ b/brewman/brewman/models/mozimo_order_sheet.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import uuid + +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Date, DateTime, ForeignKey, Numeric, UniqueConstraint, Uuid +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .stock_keeping_unit import StockKeepingUnit + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class MozimoOrderSheet: + __tablename__ = "mozimo_order_sheet" + __table_args__ = (UniqueConstraint("date", "sku_id"),) + id_: Mapped[uuid.UUID] = mapped_column("id", Uuid, primary_key=True, insert_default=uuid.uuid4) + date_: Mapped[date] = mapped_column("date", Date, nullable=False) + sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + received: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + sale: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + nc: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + # The physical stock in the display area + display: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=True) + # The physical stock in the ageing room + ageing: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=True) + last_edit_date: Mapped[datetime] = mapped_column(DateTime(), nullable=False) + + sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit") + + def __init__( + self, + date_: date, + received: Decimal, + sale: Decimal, + nc: Decimal, + display: Decimal, + ageing: Decimal, + sku_id: uuid.UUID | None = None, + sku: StockKeepingUnit | None = None, + id_: uuid.UUID | None = None, + last_edit_date: datetime | None = None, + ): + self.date_ = date_ + self.received = received + self.sale = sale + self.nc = nc + self.display = display + self.ageing = ageing + if sku_id is not None: + self.sku_id = sku_id + if sku is not None: + self.sku = sku + self.sku_id = sku.id + if id_ is not None: + self.id_ = id_ + self.last_edit_date = last_edit_date or datetime.now(UTC).replace(tzinfo=None) diff --git a/brewman/brewman/models/mozimo_stock_register.py b/brewman/brewman/models/mozimo_stock_register.py index 8a459a95..70cbb7fe 100644 --- a/brewman/brewman/models/mozimo_stock_register.py +++ b/brewman/brewman/models/mozimo_stock_register.py @@ -27,21 +27,21 @@ class MozimoStockRegister: sale: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) nc: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) # The physical stock in the display area - display: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=True) + display: Mapped[Decimal | None] = mapped_column(Numeric(precision=15, scale=2), nullable=True) # The physical stock in the ageing room - ageing: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=True) + ageing: Mapped[Decimal | None] = mapped_column(Numeric(precision=15, scale=2), nullable=True) last_edit_date: Mapped[datetime] = mapped_column(DateTime(), nullable=False) sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit") def __init__( self, - date_: datetime.date, + date_: date, received: Decimal, sale: Decimal, nc: Decimal, - display: Decimal, - ageing: Decimal, + display: Decimal | None = None, + ageing: Decimal | None = None, sku_id: uuid.UUID | None = None, sku: StockKeepingUnit | None = None, id_: uuid.UUID | None = None, diff --git a/brewman/brewman/models/product.py b/brewman/brewman/models/product.py index fd16910a..86c4a8b9 100644 --- a/brewman/brewman/models/product.py +++ b/brewman/brewman/models/product.py @@ -1,119 +1,27 @@ import uuid -from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, Text, Unicode, Uuid +from sqlalchemy import Boolean, Uuid, text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db.base_class import reg if TYPE_CHECKING: - from .account import Account - from .product_group import ProductGroup + from .product_version import ProductVersion from .stock_keeping_unit import StockKeepingUnit @reg.mapped_as_dataclass(unsafe_hash=True) class Product: __tablename__ = "products" - - id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4) - code: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) - name: Mapped[str] = mapped_column(Unicode, nullable=False, unique=True) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - fraction_units: Mapped[str] = mapped_column(Unicode, nullable=False) - product_group_id: Mapped[uuid.UUID] = mapped_column( - Uuid, - ForeignKey("product_groups.id"), - nullable=False, - ) - account_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("accounts.id"), nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) is_fixture: Mapped[bool] = mapped_column(Boolean, nullable=False) - is_purchased: Mapped[bool] = mapped_column(Boolean, nullable=False) - is_sold: Mapped[bool] = mapped_column(Boolean, nullable=False) - + versions: Mapped[list["ProductVersion"]] = relationship(back_populates="product") skus: Mapped[list["StockKeepingUnit"]] = relationship("StockKeepingUnit", back_populates="product") - product_group: Mapped["ProductGroup"] = relationship("ProductGroup", back_populates="products") - account: Mapped["Account"] = relationship("Account", back_populates="products") - - allergen: Mapped[str] = mapped_column(Text, nullable=False) - - protein: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - carbohydrate: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - total_sugar: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - added_sugar: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - total_fat: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - saturated_fat: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - trans_fat: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - cholestrol: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - sodium: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - msnf: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - other_solids: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - total_solids: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - water: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - - def __init__( - self, - name: str, - description: str | None, - fraction_units: str, - product_group_id: uuid.UUID, - account_id: uuid.UUID, - is_active: bool, - is_purchased: bool, - is_sold: bool, - allergen: str = "", - protein: Decimal = Decimal(0), - carbohydrate: Decimal = Decimal(0), - total_sugar: Decimal = Decimal(0), - added_sugar: Decimal = Decimal(0), - total_fat: Decimal = Decimal(0), - saturated_fat: Decimal = Decimal(0), - trans_fat: Decimal = Decimal(0), - cholestrol: Decimal = Decimal(0), - sodium: Decimal = Decimal(0), - msnf: Decimal = Decimal(0), - other_solids: Decimal = Decimal(0), - total_solids: Decimal = Decimal(0), - water: Decimal = Decimal(0), - code: int | None = None, - id_: uuid.UUID | None = None, - is_fixture: bool | None = False, - ) -> None: - if code is not None: - self.code = code - self.name = name - self.description = description - self.fraction_units = fraction_units - self.product_group_id = product_group_id - self.account_id = account_id - self.is_active = is_active - self.is_purchased = is_purchased - self.is_sold = is_sold - - self.allergen = allergen - self.protein = protein - self.carbohydrate = carbohydrate - self.total_sugar = total_sugar - self.added_sugar = added_sugar - self.total_fat = total_fat - self.saturated_fat = saturated_fat - self.trans_fat = trans_fat - self.cholestrol = cholestrol - self.sodium = sodium - self.msnf = msnf - self.other_solids = other_solids - self.total_solids = total_solids - self.water = water + def __init__(self, id_: uuid.UUID | None = None, is_fixture: bool | None = None) -> None: if id_ is not None: self.id = id_ - if is_fixture is not None: - self.is_fixture = is_fixture - - @classmethod - def suspense(cls) -> uuid.UUID: - return uuid.UUID("aa79a643-9ddc-4790-ac7f-a41f9efb4c15") + self.is_fixture = is_fixture if is_fixture is not None else False diff --git a/brewman/brewman/models/product_group.py b/brewman/brewman/models/product_group.py index 6134174b..326907d9 100644 --- a/brewman/brewman/models/product_group.py +++ b/brewman/brewman/models/product_group.py @@ -9,7 +9,7 @@ from ..db.base_class import reg if TYPE_CHECKING: - from .product import Product + from .product_version import ProductVersion @reg.mapped_as_dataclass(unsafe_hash=True) @@ -22,7 +22,7 @@ class ProductGroup: ice_cream: Mapped[bool] = mapped_column(Boolean, nullable=False) is_fixture: Mapped[bool] = mapped_column(Boolean, nullable=False) - products: Mapped[list["Product"]] = relationship("Product", back_populates="product_group") + products: Mapped[list["ProductVersion"]] = relationship("ProductVersion", back_populates="product_group") def __init__( self, diff --git a/brewman/brewman/models/product_version.py b/brewman/brewman/models/product_version.py new file mode 100644 index 00000000..162e5449 --- /dev/null +++ b/brewman/brewman/models/product_version.py @@ -0,0 +1,150 @@ +import re +import uuid + +from datetime import date +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, Date, ForeignKey, Numeric, Text, Unicode, Uuid, func, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .account import Account + from .product import Product + from .product_group import ProductGroup + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class ProductVersion: + __tablename__ = "product_versions" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4) + product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) + handle: Mapped[str] = mapped_column(Unicode, nullable=False) + name: Mapped[str] = mapped_column(Unicode, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + fraction_units: Mapped[str] = mapped_column(Unicode, nullable=False) + product_group_id: Mapped[uuid.UUID] = mapped_column( + Uuid, + ForeignKey("product_groups.id"), + nullable=False, + ) + account_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("accounts.id"), nullable=False) + is_purchased: Mapped[bool] = mapped_column(Boolean, nullable=False) + is_sold: Mapped[bool] = mapped_column(Boolean, nullable=False) + + product: Mapped["Product"] = relationship("Product", back_populates="versions") + product_group: Mapped["ProductGroup"] = relationship("ProductGroup", back_populates="products") + account: Mapped["Account"] = relationship("Account", back_populates="products") + + allergen: Mapped[str] = mapped_column(Text, nullable=False) + + protein: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + carbohydrate: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + total_sugar: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + added_sugar: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + total_fat: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + saturated_fat: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + trans_fat: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + cholestrol: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + sodium: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + msnf: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + other_solids: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + total_solids: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + water: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + + valid_from: Mapped[date | None] = mapped_column(Date(), nullable=True) + valid_till: Mapped[date | None] = mapped_column(Date(), nullable=True) + + __table_args__ = ( + postgresql.ExcludeConstraint( + (name, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), # type: ignore[no-untyped-call] + postgresql.ExcludeConstraint( + (handle, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), # type: ignore[no-untyped-call] + postgresql.ExcludeConstraint( + (product_id, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), # type: ignore[no-untyped-call] + ) + + def __init__( + self, + product_id: uuid.UUID | None = None, + name: str = "", + description: str | None = None, + handle: str | None = None, + fraction_units: str = "", + product_group_id: uuid.UUID | None = None, + account_id: uuid.UUID | None = None, + is_purchased: bool = False, + is_sold: bool = False, + allergen: str = "", + protein: Decimal = Decimal(0), + carbohydrate: Decimal = Decimal(0), + total_sugar: Decimal = Decimal(0), + added_sugar: Decimal = Decimal(0), + total_fat: Decimal = Decimal(0), + saturated_fat: Decimal = Decimal(0), + trans_fat: Decimal = Decimal(0), + cholestrol: Decimal = Decimal(0), + sodium: Decimal = Decimal(0), + msnf: Decimal = Decimal(0), + other_solids: Decimal = Decimal(0), + total_solids: Decimal = Decimal(0), + water: Decimal = Decimal(0), + id_: uuid.UUID | None = None, + valid_from: date | None = None, + valid_till: date | None = None, + ) -> None: + if product_id is not None: + self.product_id = product_id + self.name = name + self.description = description + self.handle = self.slugify(name) if handle is None else handle + self.fraction_units = fraction_units + if product_group_id is not None: + self.product_group_id = product_group_id + if account_id is not None: + self.account_id = account_id + self.is_purchased = is_purchased + self.is_sold = is_sold + + self.allergen = allergen + self.protein = protein + self.carbohydrate = carbohydrate + self.total_sugar = total_sugar + self.added_sugar = added_sugar + self.total_fat = total_fat + self.saturated_fat = saturated_fat + self.trans_fat = trans_fat + self.cholestrol = cholestrol + self.sodium = sodium + self.msnf = msnf + self.other_solids = other_solids + self.total_solids = total_solids + self.water = water + + if id_ is not None: + self.id = id_ + self.valid_from = valid_from + self.valid_till = valid_till + + @classmethod + def suspense(cls) -> uuid.UUID: + return uuid.UUID("aa79a643-9ddc-4790-ac7f-a41f9efb4c15") + + @staticmethod + def slugify(value: str) -> str: + value = value.strip().lower() + value = re.sub(r"[^\w\s-]", "", value) + value = re.sub(r"[\s_-]+", "-", value) + value = re.sub(r"^-+|-+$", "", value) + return value diff --git a/brewman/brewman/models/sku_version.py b/brewman/brewman/models/sku_version.py new file mode 100644 index 00000000..ccc6864a --- /dev/null +++ b/brewman/brewman/models/sku_version.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import uuid + +from datetime import date +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Date, ForeignKey, Numeric, Unicode, Uuid, func, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .stock_keeping_unit import StockKeepingUnit + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class SkuVersion: + __tablename__ = "sku_versions" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4) + # product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) # should i remove this? + sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + units: Mapped[str] = mapped_column(Unicode, nullable=False) + fraction: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + product_yield: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) + cost_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + sale_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + + valid_from: Mapped[date | None] = mapped_column(Date(), nullable=True) + valid_till: Mapped[date | None] = mapped_column(Date(), nullable=True) + + sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", back_populates="versions") + + __table_args__ = ( + # postgresql.ExcludeConstraint( + # (product_id, "="), + # (units, "="), + # (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + # ), # type: ignore[no-untyped-call] + postgresql.ExcludeConstraint( + (sku_id, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), # type: ignore[no-untyped-call] + ) + + def __init__( + self, + units: str = "", + fraction: Decimal = Decimal("1.0"), + product_yield: Decimal = Decimal("1.0"), + cost_price: Decimal = Decimal("0.0"), + sale_price: Decimal = Decimal("0.0"), + # product_id: uuid.UUID | None = None, + # product: Product | None = None, + sku_id: uuid.UUID | None = None, + sku: StockKeepingUnit | None = None, + id_: uuid.UUID | None = None, + valid_from: date | None = None, + valid_till: date | None = None, + ) -> None: + # if product_id is not None: + # self.product_id = product_id + if sku_id is not None: + self.sku_id = sku_id + self.units = units + self.fraction = fraction + self.product_yield = product_yield + self.cost_price = cost_price + self.sale_price = sale_price + if id_ is not None: + self.id = id_ + # if product is not None: + # self.product = product + if sku is not None: + self.sku = sku + self.valid_from = valid_from + self.valid_till = valid_till diff --git a/brewman/brewman/models/stock_keeping_unit.py b/brewman/brewman/models/stock_keeping_unit.py index 2defdbc0..8a46a585 100644 --- a/brewman/brewman/models/stock_keeping_unit.py +++ b/brewman/brewman/models/stock_keeping_unit.py @@ -2,10 +2,9 @@ from __future__ import annotations import uuid -from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Numeric, Unicode, UniqueConstraint, Uuid +from sqlalchemy import ForeignKey, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db.base_class import reg @@ -15,43 +14,29 @@ if TYPE_CHECKING: from .batch import Batch from .product import Product from .recipe import Recipe + from .sku_version import SkuVersion @reg.mapped_as_dataclass(unsafe_hash=True) class StockKeepingUnit: __tablename__ = "stock_keeping_units" - __table_args__ = (UniqueConstraint("product_id", "units"),) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4) - product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) - units: Mapped[str] = mapped_column(Unicode, nullable=False) - fraction: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - product_yield: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) - cost_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) - sale_price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), unique=True, nullable=False) product: Mapped[Product] = relationship("Product", back_populates="skus") batches: Mapped[list[Batch]] = relationship("Batch", back_populates="sku") recipes: Mapped[list[Recipe]] = relationship("Recipe", back_populates="sku") + versions: Mapped[list[SkuVersion]] = relationship(back_populates="sku") def __init__( self, - units: str, - fraction: Decimal, - product_yield: Decimal, - cost_price: Decimal, - sale_price: Decimal, product_id: uuid.UUID | None = None, product: Product | None = None, id_: uuid.UUID | None = None, ) -> None: if product_id is not None: self.product_id = product_id - self.units = units - self.fraction = fraction - self.product_yield = product_yield - self.cost_price = cost_price - self.sale_price = sale_price if id_ is not None: self.id = id_ if product is not None: diff --git a/brewman/brewman/routers/__init__.py b/brewman/brewman/routers/__init__.py index 90d0ccd5..3a74c13d 100644 --- a/brewman/brewman/routers/__init__.py +++ b/brewman/brewman/routers/__init__.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from datetime import date, timedelta +from datetime import UTC, date, datetime, timedelta from sqlalchemy import or_, select from sqlalchemy.orm import Session @@ -73,3 +73,13 @@ def get_lock_info( return True, "Voucher allowed" return False, "Default Fallthrough" + + +def effective_date(d: str | None = None) -> date: + return ( + ( + datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=330) # Adjust for IST (Indian Standard Time) + ).date() + if d is None + else datetime.strptime(d, "%d-%b-%Y").date() + ) diff --git a/brewman/brewman/routers/batch.py b/brewman/brewman/routers/batch.py index 4c86f907..69a0d26d 100644 --- a/brewman/brewman/routers/batch.py +++ b/brewman/brewman/routers/batch.py @@ -1,11 +1,14 @@ import datetime from fastapi import APIRouter, Depends -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import contains_eager import brewman.schemas.batch as schemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.batch import Batch @@ -26,21 +29,50 @@ def batch_term( date_ = datetime.datetime.strptime(d, "%d-%b-%Y") list_: list[schemas.Batch] = [] with SessionFuture() as db: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) query = ( select(Batch) .join(Batch.sku) .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(SkuVersion, onclause=sku_version_onclause) .where(Batch.quantity_remaining > 0, Batch.name <= date_) - .options(contains_eager(Batch.sku).contains_eager(StockKeepingUnit.product)) + .options( + contains_eager(Batch.sku).contains_eager(StockKeepingUnit.product).contains_eager(Product.versions), + contains_eager(Batch.sku).contains_eager(StockKeepingUnit.versions), + ) ) if q is not None: for q_item in q.split(): - query = query.where(Product.name.ilike(f"%{q_item}%")) - result = db.execute(query.order_by(Product.name).order_by(Batch.name)).scalars().all() + query = query.where(ProductVersion.name.ilike(f"%{q_item}%")) + result = db.execute(query.order_by(ProductVersion.name).order_by(Batch.name)).unique().scalars().all() for item in result: + product_name = item.sku.product.versions[0].name + product_units = item.sku.versions[0].units text = ( - f"{item.sku.product.name} ({item.sku.units}) {item.quantity_remaining:.2f}@" + f"{product_name} ({product_units}) {item.quantity_remaining:.2f}@" f"{item.rate:.2f} from {item.name.strftime('%d-%b-%Y')}" ) list_.append( @@ -53,7 +85,7 @@ def batch_term( discount=round(item.discount, 5), sku=schemas.ProductLink( id_=item.sku.id, - name=f"{item.sku.product.name} ({item.sku.units})", + name=f"{product_name} ({product_units})", ), ) ) diff --git a/brewman/brewman/routers/batch_integrity.py b/brewman/brewman/routers/batch_integrity.py index f42351e6..2fb165e5 100644 --- a/brewman/brewman/routers/batch_integrity.py +++ b/brewman/brewman/routers/batch_integrity.py @@ -3,11 +3,13 @@ import uuid from decimal import Decimal from fastapi import APIRouter, Security -from sqlalchemy import distinct, func, select, update +from sqlalchemy import and_, distinct, func, or_, select, update from sqlalchemy.orm import Session, contains_eager import brewman.schemas.batch_integrity as schemas +from brewman.models.product_version import ProductVersion + from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.batch import Batch @@ -37,17 +39,29 @@ def post_check_batch_integrity( def negative_batches(db: Session) -> list[schemas.BatchIntegrity]: inv_sum = func.sum(Inventory.quantity * Journal.debit).label("quantity") - list_ = db.execute( - select(Batch, Product.name, inv_sum) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) + list_: list[tuple[Batch, str, Decimal]] = db.execute( + select(Batch, ProductVersion.name, inv_sum) .join(Batch.sku) .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) .join(Batch.inventories) .join(Inventory.voucher) .join(Voucher.journals) .where( Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(Batch, Product.name) # type: ignore + .group_by(Batch, ProductVersion.name) # type: ignore .having(Batch.quantity_remaining != inv_sum) ).all() @@ -93,11 +107,23 @@ def batch_details(batch_id: uuid.UUID, db: Session) -> list[schemas.BatchIntegri def batch_dates(db: Session) -> list[schemas.BatchIntegrity]: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) list_ = ( db.execute( select(Batch) .join(Batch.sku) .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) .join(Batch.inventories) .join(Inventory.voucher) .where(Voucher.date_ < Batch.name) @@ -116,7 +142,7 @@ def batch_dates(db: Session) -> list[schemas.BatchIntegrity]: issue.append( schemas.BatchIntegrity( id_=batch.id, - product=batch.sku.product.name, + product=batch.sku.product.versions[0].name, date_=batch.name, showing=batch.quantity_remaining, actual=Decimal(0), diff --git a/brewman/brewman/routers/calculate_prices.py b/brewman/brewman/routers/calculate_prices.py index 17d49150..8fc685a6 100644 --- a/brewman/brewman/routers/calculate_prices.py +++ b/brewman/brewman/routers/calculate_prices.py @@ -3,7 +3,7 @@ import uuid from decimal import Decimal from fastapi import HTTPException, status -from sqlalchemy import distinct, func, select +from sqlalchemy import and_, distinct, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -14,6 +14,7 @@ from brewman.models.inventory import Inventory from brewman.models.journal import Journal from brewman.models.period import Period from brewman.models.price import Price +from brewman.models.sku_version import SkuVersion from brewman.models.voucher import Voucher from brewman.models.voucher_type import VoucherType @@ -52,16 +53,29 @@ def calculate_prices(period_id: uuid.UUID, db: Session) -> None: def get_issue_prices(period: Period, products: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]: - sum_quantity = func.sum( - Inventory.quantity * StockKeepingUnit.fraction * StockKeepingUnit.product_yield * Journal.debit - ).label("quantity") + sum_quantity = func.sum(Inventory.quantity * SkuVersion.fraction * SkuVersion.product_yield * Journal.debit).label( + "quantity" + ) sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net") - + issue_price = (sum_net / sum_quantity).label("issue_price") + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) d: dict[uuid.UUID, Decimal] = {} query = db.execute( - select(StockKeepingUnit.product_id, sum_net / sum_quantity) + select(StockKeepingUnit.product_id, issue_price) + .select_from(Inventory) .join(Inventory.batch) .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) .join(Inventory.voucher) .join(Voucher.journals) .where( @@ -79,16 +93,30 @@ def get_issue_prices(period: Period, products: set[uuid.UUID], db: Session) -> d def get_purchase_prices(period: Period, req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]: - sum_quantity = func.sum( - Inventory.quantity * StockKeepingUnit.fraction * StockKeepingUnit.product_yield * Journal.debit - ).label("quantity") + sum_quantity = func.sum(Inventory.quantity * SkuVersion.fraction * SkuVersion.product_yield * Journal.debit).label( + "quantity" + ) sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net") - + purchase_price = (sum_net / sum_quantity).label("purchase_price") + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) d: dict[uuid.UUID, Decimal] = {} + query = db.execute( - select(StockKeepingUnit.product_id, sum_net / sum_quantity) + select(StockKeepingUnit.product_id, purchase_price) + .select_from(Inventory) .join(Inventory.batch) .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) .join(Inventory.voucher) .join(Voucher.journals) .where( @@ -105,12 +133,26 @@ def get_purchase_prices(period: Period, req: set[uuid.UUID], db: Session) -> dic def get_rest(req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]: + rest_price = (SkuVersion.cost_price / (SkuVersion.fraction * SkuVersion.product_yield)).label("rest_price") + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) d: dict[uuid.UUID, Decimal] = {} query = db.execute( select( StockKeepingUnit.product_id, - StockKeepingUnit.cost_price / (StockKeepingUnit.fraction * StockKeepingUnit.product_yield), - ).where(StockKeepingUnit.product_id.in_(req)) + rest_price, + ) + .join(SkuVersion, onclause=sku_version_onclause) + .where(StockKeepingUnit.product_id.in_(req)) ).all() for id, amount in query: d[id] = amount @@ -127,6 +169,8 @@ def calculate_recipes(recipes: set[uuid.UUID], prices: dict[uuid.UUID, Decimal], .all() ) for item in items: - cost = sum(i.quantity * prices[i.product_id] for i in item.items) / (item.recipe_yield * item.sku.fraction) + cost = sum(i.quantity * prices[i.product_id] for i in item.items) / ( + item.recipe_yield * item.sku.versions[-1].fraction + ) prices[item.sku.product_id] = cost recipes.remove(item.sku.product_id) diff --git a/brewman/brewman/routers/employee_attendance.py b/brewman/brewman/routers/employee_attendance.py index 45e99a46..8b51ef52 100644 --- a/brewman/brewman/routers/employee_attendance.py +++ b/brewman/brewman/routers/employee_attendance.py @@ -48,7 +48,7 @@ def employee_attendance_report( finish_date = datetime.strptime(f or get_finish_date(request.session), "%d-%b-%Y").date() start_date = employee.joining_date if employee.joining_date > start_date else start_date finish_date = ( - employee.leaving_date if not employee.is_active and employee.leaving_date < finish_date else finish_date # type: ignore[assignment, operator] + employee.leaving_date if not employee.is_active and employee.leaving_date < finish_date else finish_date # type: ignore[assignment] ) info = schemas.EmployeeAttendance( start_date=start_date, diff --git a/brewman/brewman/routers/fingerprint.py b/brewman/brewman/routers/fingerprint.py index f81dba2c..eb485af7 100644 --- a/brewman/brewman/routers/fingerprint.py +++ b/brewman/brewman/routers/fingerprint.py @@ -95,7 +95,7 @@ def fp(file_data: StringIO, employees: dict[int, uuid.UUID]) -> list[schemas.Fin def get_prints(employee_id: uuid.UUID, date_: date, db: Session) -> tuple[str, str, bool]: - prints = ( + prints = list( db.execute( select(Fingerprint) .where( diff --git a/brewman/brewman/routers/issue.py b/brewman/brewman/routers/issue.py index 74c0909c..101238f9 100644 --- a/brewman/brewman/routers/issue.py +++ b/brewman/brewman/routers/issue.py @@ -4,13 +4,16 @@ from datetime import UTC, datetime from decimal import Decimal from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status -from sqlalchemy import distinct, func, or_, select, update +from sqlalchemy import and_, distinct, func, or_, select, update from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, contains_eager import brewman.schemas.input as schema_in import brewman.schemas.voucher as output +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ..core.security import get_current_active_user as get_user from ..core.session import get_date, set_date from ..db.session import SessionFuture @@ -69,9 +72,25 @@ def save_route( def save(data: schema_in.IssueIn, user: UserToken, db: Session) -> tuple[Voucher, bool | None]: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= data.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= data.date_, + ), + ) product_accounts = ( - select(Product.account_id) + select(ProductVersion.account_id) + .join(Product, onclause=product_version_onclause) .join(Product.skus) + .options( + contains_eager(ProductVersion.product).contains_eager(Product.versions), + contains_eager(Product.skus), + ) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) ) account_types: list[int] = list( @@ -116,16 +135,45 @@ def save_inventories( amount: Decimal = Decimal(0) for data_item in inventories: batch = db.execute(select(Batch).where(Batch.id == data_item.batch.id_)).scalar_one() + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() + product_unit = db.execute( + select(SkuVersion.units).where( + and_( + SkuVersion.sku_id == batch.sku_id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() if batch_consumed and data_item.quantity > batch.quantity_remaining: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{batch.sku.product.name} ({batch.sku.units}) stock is {batch.quantity_remaining}", + detail=f"{product_name} ({product_unit}) stock is {batch.quantity_remaining}", ) if batch.name > voucher.date_: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{batch.sku.product.name} ({batch.sku.units}) " - f"was purchased on {batch.name.strftime('%d-%b-%Y')}", + detail=f"{product_name} ({product_unit}) was purchased on {batch.name.strftime('%d-%b-%Y')}", ) if batch_consumed is None: pass @@ -205,9 +253,25 @@ def update_voucher( id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session ) -> tuple[Voucher, bool | None]: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= data.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= data.date_, + ), + ) product_accounts = ( - select(Product.account_id) + select(ProductVersion.account_id) + .join(Product, onclause=product_version_onclause) .join(Product.skus) + .options( + contains_eager(ProductVersion.product).contains_eager(Product.versions), + contains_eager(Product.skus), + ) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) ) account_types: list[int] = list( @@ -280,18 +344,47 @@ def update_inventories( index = next( (idx for (idx, d) in enumerate(inventories) if d.id_ == item.id and d.batch.id_ == item.batch.id), None ) + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() + product_unit = db.execute( + select(SkuVersion.units).where( + and_( + SkuVersion.sku_id == batch.sku_id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() if index is not None: new_inventory = inventories.pop(index) if batch_consumed and new_inventory.quantity > batch_quantity: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Maximum quantity available for " - f"{batch.sku.product.name} ({batch.sku.units}) is {batch_quantity}", + detail=f"Maximum quantity available for {product_name} ({product_unit}) is {batch_quantity}", ) if item.batch.name > voucher.date_: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Batch of {batch.sku.product.name} ({batch.sku.units}) was purchased after the issue date", + detail=f"Batch of {product_name} ({product_unit}) was purchased after the issue date", ) if batch_consumed is None: @@ -315,7 +408,7 @@ def update_inventories( if batch_quantity < item.quantity: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Product {batch.sku.product.name} ({batch.sku.units}) cannot be removed," + detail=f"Product {product_name} ({product_unit}) cannot be removed," f" minimum quantity is {batch_quantity}", ) item.batch.quantity_remaining = batch_quantity diff --git a/brewman/brewman/routers/product.py b/brewman/brewman/routers/product.py index 977f51c6..6fb3cb69 100644 --- a/brewman/brewman/routers/product.py +++ b/brewman/brewman/routers/product.py @@ -1,25 +1,30 @@ import uuid -from datetime import datetime +from collections.abc import Sequence +from datetime import date, timedelta from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, Security, status -from sqlalchemy import delete, desc, func, or_, select +from sqlalchemy import and_, or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, contains_eager import brewman.schemas.product as schemas +from brewman.models.sku_version import SkuVersion + from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.account import Account -from ..models.batch import Batch from ..models.product import Product +from ..models.product_group import ProductGroup +from ..models.product_version import ProductVersion from ..models.rate_contract import RateContract from ..models.rate_contract_item import RateContractItem from ..models.stock_keeping_unit import StockKeepingUnit from ..schemas.product_sku import ProductSku from ..schemas.user import UserToken +from . import effective_date router = APIRouter() @@ -32,13 +37,17 @@ def save( ) -> schemas.Product: try: with SessionFuture() as db: - item = Product( + product = Product() + db.add(product) + db.flush() # So product.id is available + + version = ProductVersion( + product_id=product.id, name=data.name, description=data.description, fraction_units=data.fraction_units, product_group_id=data.product_group.id_, account_id=Account.all_purchases(), - is_active=data.is_active, is_purchased=data.is_purchased, is_sold=data.is_sold, allergen=data.allergen, @@ -55,27 +64,34 @@ def save( other_solids=data.other_solids, total_solids=data.total_solids, water=data.water, + valid_from=None, + valid_till=None, ) - item.code = db.execute(select(func.coalesce(func.max(Product.code), 0) + 1)).scalar_one() - db.add(item) + db.add(version) + if not len(data.skus): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Not enough stock keeping units.", ) for sku in data.skus: - db.add( - StockKeepingUnit( - units=sku.units, - fraction=round(sku.fraction, 5), - product_yield=round(sku.product_yield, 5), - cost_price=round(sku.cost_price, 2), - sale_price=round(sku.sale_price, 2), - product=item, - ) + s = StockKeepingUnit(product=product) + product.skus.append(s) + db.add(s) + db.flush() # So sku.id is available + sver = SkuVersion( + units=sku.units, + fraction=round(sku.fraction, 5), + product_yield=round(sku.product_yield, 5), + cost_price=round(sku.cost_price, 2), + sale_price=round(sku.sale_price, 2), + sku=s, ) + s.versions.append(sver) + db.add(sver) db.commit() - return product_info(item) + db.refresh(product) + return product_info(version, [s.versions[0] for s in product.skus]) except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -87,77 +103,184 @@ def save( def update_route( id_: uuid.UUID, data: schemas.ProductIn, + date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), ) -> schemas.Product: try: with SessionFuture() as db: - item: Product = db.execute(select(Product).where(Product.id == id_)).scalar_one() - if item.is_fixture: + version: ProductVersion = db.execute( + select(ProductVersion).where( + and_( + ProductVersion.product_id == id_, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + ) + ).scalar_one() + if version.product.is_fixture: raise HTTPException( status_code=status.HTTP_423_LOCKED, - detail=f"{item.name} is a fixture and cannot be edited or deleted.", + detail=f"{version.name} is a fixture and cannot be edited or deleted.", ) - item.name = data.name - item.description = data.description - item.fraction_units = data.fraction_units - item.product_group_id = data.product_group.id_ - item.account_id = Account.all_purchases() - item.is_active = data.is_active - item.is_purchased = data.is_purchased - item.is_sold = data.is_sold - - item.allergen = data.allergen - item.protein = data.protein - item.carbohydrate = data.carbohydrate - item.total_sugar = data.total_sugar - item.added_sugar = data.added_sugar - item.total_fat = data.total_fat - item.saturated_fat = data.saturated_fat - item.trans_fat = data.trans_fat - item.cholestrol = data.cholestrol - item.sodium = data.sodium - item.msnf = data.msnf - item.other_solids = data.other_solids - item.total_solids = data.total_solids - item.water = data.water + changed = ( + data.name != version.name + or data.description != version.description + or data.fraction_units != version.fraction_units + or data.is_purchased != version.is_purchased + or data.is_sold != version.is_sold + or data.allergen != version.allergen + or Decimal(data.protein).quantize(Decimal("0.00001")) != version.protein + or Decimal(data.carbohydrate).quantize(Decimal("0.00001")) != version.carbohydrate + or Decimal(data.total_sugar).quantize(Decimal("0.00001")) != version.total_sugar + or Decimal(data.added_sugar).quantize(Decimal("0.00001")) != version.added_sugar + or Decimal(data.total_fat).quantize(Decimal("0.00001")) != version.total_fat + or Decimal(data.saturated_fat).quantize(Decimal("0.00001")) != version.saturated_fat + or Decimal(data.trans_fat).quantize(Decimal("0.00001")) != version.trans_fat + or Decimal(data.cholestrol).quantize(Decimal("0.00001")) != version.cholestrol + or Decimal(data.sodium).quantize(Decimal("0.00001")) != version.sodium + or Decimal(data.msnf).quantize(Decimal("0.00001")) != version.msnf + or Decimal(data.other_solids).quantize(Decimal("0.00001")) != version.other_solids + or Decimal(data.total_solids).quantize(Decimal("0.00001")) != version.total_solids + or Decimal(data.water).quantize(Decimal("0.00001")) != version.water + or version.product_group_id != data.product_group.id_ + or version.account_id != Account.all_purchases() + ) + if changed: + new_version = ( + version if version.valid_from == date_ else ProductVersion(product_id=id_, valid_from=date_) + ) + new_version.handle = ProductVersion.slugify(data.name) + new_version.name = data.name + new_version.description = data.description + new_version.fraction_units = data.fraction_units + new_version.product_group_id = data.product_group.id_ + new_version.account_id = Account.all_purchases() + new_version.is_purchased = data.is_purchased + new_version.is_sold = data.is_sold + new_version.allergen = data.allergen + new_version.protein = data.protein + new_version.carbohydrate = data.carbohydrate + new_version.total_sugar = data.total_sugar + new_version.added_sugar = data.added_sugar + new_version.total_fat = data.total_fat + new_version.saturated_fat = data.saturated_fat + new_version.trans_fat = data.trans_fat + new_version.cholestrol = data.cholestrol + new_version.sodium = data.sodium + new_version.msnf = data.msnf + new_version.other_solids = data.other_solids + new_version.total_solids = data.total_solids + new_version.water = data.water + if version.valid_from != date_: + version.valid_till = date_ - timedelta(days=1) + version.product.versions.append(new_version) + db.add(new_version) if not len(data.skus): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Not enough stock keeping units.", ) - for i in range(len(item.skus), 0, -1): - sku = item.skus[i - 1] + old_skus = ( + db.execute( + select(SkuVersion) + .join(SkuVersion.sku) + .options(contains_eager(SkuVersion.sku)) + .where( + and_( + StockKeepingUnit.product_id == id_, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + ) + ) + .scalars() + .all() + ) + for i in range(len(old_skus), 0, -1): + sku: SkuVersion = old_skus[i - 1] index = next((idx for (idx, d) in enumerate(data.skus) if d.id_ == sku.id), None) if index is not None: new_data_sku = data.skus.pop(index) - sku.units = new_data_sku.units - sku.fraction = round(new_data_sku.fraction, 5) - sku.product_yield = round(new_data_sku.product_yield, 5) - sku.cost_price = round(new_data_sku.cost_price, 2) - sku.sale_price = round(new_data_sku.sale_price, 2) - else: - count: int = db.execute(select(func.count()).where(Batch.sku_id == sku.id)).scalar_one() - if count > 0: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"SKU '{sku.units}' has entries and cannot be deleted.", + sku_changed = ( + new_data_sku.units != sku.units + or Decimal(new_data_sku.fraction).quantize(Decimal("0.00001")) != sku.fraction + or Decimal(new_data_sku.product_yield).quantize(Decimal("0.00001")) != sku.product_yield + or Decimal(new_data_sku.cost_price).quantize(Decimal("0.01")) != sku.cost_price + or Decimal(new_data_sku.sale_price).quantize(Decimal("0.01")) != sku.sale_price + ) + + print(f"SKU Changed: {sku_changed} for {sku.id}") + if sku_changed: + new_sku = ( + sku + if sku.valid_from == date_ + else SkuVersion( + sku_id=sku.sku_id, + valid_from=date_, + ) ) - item.skus.remove(sku) - db.delete(sku) + new_sku.units = new_data_sku.units + new_sku.fraction = round(new_data_sku.fraction, 5) + new_sku.product_yield = round(new_data_sku.product_yield, 5) + new_sku.cost_price = round(new_data_sku.cost_price, 2) + new_sku.sale_price = round(new_data_sku.sale_price, 2) + + if sku.valid_from != date_: + sku.valid_till = date_ - timedelta(days=1) + db.add(new_sku) + else: + sku.valid_till = date_ - timedelta(days=1) for data_sku in data.skus: - new_sku = StockKeepingUnit( + new_s = StockKeepingUnit(product_id=version.product_id) + version.product.skus.append(new_s) + db.add(new_s) + db.flush() # So sku.id is available + new_sku = SkuVersion( units=data_sku.units, fraction=round(data_sku.fraction, 5), product_yield=round(data_sku.product_yield, 5), cost_price=round(data_sku.cost_price, 2), sale_price=round(data_sku.sale_price, 2), - product=item, + sku=new_s, ) db.add(new_sku) - item.skus.append(new_sku) + current_skus = ( + db.execute( + select(SkuVersion) + .join(SkuVersion.sku) + .options(contains_eager(SkuVersion.sku)) + .where( + and_( + StockKeepingUnit.product_id == id_, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + ) + ) + .scalars() + .all() + ) db.commit() - return product_info(item) + return product_info(version, current_skus) except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -168,6 +291,7 @@ def update_route( @router.delete("/{id_}", response_model=schemas.ProductBlank) def delete_route( id_: uuid.UUID, + date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), ) -> schemas.ProductBlank: with SessionFuture() as db: @@ -175,17 +299,49 @@ def delete_route( if item.is_fixture: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"{item.name} is a fixture and cannot be edited or deleted.", + detail="This product is a fixture and cannot be edited or deleted.", + ) + version: ProductVersion = db.execute( + select(ProductVersion).where( + and_( + ProductVersion.product_id == id_, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) ) - count: int = db.execute( - select(func.count()).join(StockKeepingUnit.batches).where(StockKeepingUnit.product_id == id_) ).scalar_one() - if count > 0: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Product has entries and cannot be deleted." + skus = ( + db.execute( + select(SkuVersion) + .join(SkuVersion.sku) + .options(contains_eager(SkuVersion.sku)) + .where( + and_( + StockKeepingUnit.product_id == id_, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + ) ) - db.execute(delete(StockKeepingUnit).where(StockKeepingUnit.product_id == id_)) - db.execute(delete(Product).where(Product.id == id_)) + .scalars() + .all() + ) + valid_till = date_ - timedelta(days=1) + version.valid_till = valid_till + for sku in skus: + sku.valid_till = valid_till db.commit() return product_blank() @@ -198,20 +354,45 @@ def show_blank( @router.get("/list", response_model=list[schemas.Product]) -def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Product]: +def show_list(date_: date = Depends(effective_date), user: UserToken = Depends(get_user)) -> list[schemas.Product]: with SessionFuture() as db: + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) return [ - product_info(item) + product_info(item, [s.versions[0] for s in item.product.skus]) for item in db.execute( - select(Product) - .join(Product.product_group) + select(ProductVersion) + .join(ProductVersion.product) + .join(ProductVersion.product_group) + .join(ProductVersion.product) .join(Product.skus) - .order_by(desc(Product.is_active)) - .order_by(Product.product_group_id) - .order_by(Product.name) + .join(SkuVersion, onclause=sku_version_onclause) + .where( + and_( + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + ) + .order_by(ProductGroup.name) + .order_by(ProductVersion.name) .options( - contains_eager(Product.skus), - contains_eager(Product.product_group), + contains_eager(ProductVersion.product, Product.skus, StockKeepingUnit.versions), + contains_eager(ProductVersion.product_group), ) ) .unique() @@ -226,30 +407,64 @@ async def show_term_sku( a: bool | None = None, # Active p: bool | None = None, # Is Purchased? v: uuid.UUID | None = None, # Vendor - d: str | None = None, # Date + date_: date = Depends(effective_date), # Date current_user: UserToken = Depends(get_user), ) -> list[ProductSku]: list_ = [] with SessionFuture() as db: - query_ = select(Product).join(Product.skus).options(contains_eager(Product.skus)) - if a is not None: - query_ = query_.filter(Product.is_active == a) + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + query_ = ( + select(ProductVersion) + .join(ProductVersion.product) + .join(ProductVersion.product_group) + .join(ProductVersion.product) + .join(Product.skus) + .join(SkuVersion, onclause=sku_version_onclause) + .where( + and_( + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + ) + ) + # if a is not None: + # query_ = query_.filter(Product.is_active == a) if p is not None: - query_ = query_.filter(Product.is_purchased == p) + query_ = query_.filter(ProductVersion.is_purchased == p) if q is not None: for sub in q.split(): if sub.strip() != "": query_ = query_.filter( - or_(Product.name.ilike(f"%{sub}%"), StockKeepingUnit.units.ilike(f"%{sub}%")) + or_(ProductVersion.name.ilike(f"%{sub}%"), SkuVersion.units.ilike(f"%{sub}%")) ) - query_ = query_.order_by(Product.name) + query_ = query_.order_by(ProductVersion.name) + query_ = query_.options( + contains_eager(ProductVersion.product, Product.skus, StockKeepingUnit.versions), + contains_eager(ProductVersion.product_group), + ) for item in db.execute(query_).unique().scalars().all(): - for sku in item.skus: - rc_price = get_rc_price(item.id, d, v, db) + for sku in [sku.versions[0] for sku in item.product.skus]: + rc_price = get_rc_price(item.id, date_, v, db) list_.append( ProductSku( - id_=sku.id, + id_=sku.sku_id, name=f"{item.name} ({sku.units})", fraction_units=item.fraction_units, cost_price=sku.cost_price if rc_price is None else rc_price, @@ -265,20 +480,33 @@ async def show_term_product( q: str | None = None, # Query a: bool | None = None, # Active p: bool | None = None, # Is Purchased? + date_: date = Depends(effective_date), # Date current_user: UserToken = Depends(get_user), ) -> list[ProductSku]: list_ = [] with SessionFuture() as db: - query_ = select(Product) - if a is not None: - query_ = query_.filter(Product.is_active == a) + query_ = select(ProductVersion).where( + and_( + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + ) + + # if a is not None: + # query_ = query_.filter(Product.is_active == a) if p is not None: - query_ = query_.filter(Product.is_purchased == p) + query_ = query_.filter(ProductVersion.is_purchased == p) if q is not None: for sub in q.split(): if sub.strip() != "": - query_ = query_.filter(Product.name.ilike(f"%{sub}%")) - query_ = query_.order_by(Product.name) + query_ = query_.filter(ProductVersion.name.ilike(f"%{sub}%")) + query_ = query_.order_by(ProductVersion.name) for item in db.execute(query_).unique().scalars().all(): list_.append( @@ -297,20 +525,57 @@ async def show_term_product( @router.get("/{id_}", response_model=schemas.Product) def show_id( id_: uuid.UUID, + date_: date = Depends(effective_date), user: UserToken = Security(get_user, scopes=["products"]), ) -> schemas.Product: with SessionFuture() as db: - item: Product = db.execute(select(Product).where(Product.id == id_)).scalar_one() - return product_info(item) + version: ProductVersion = db.execute( + select(ProductVersion).where( + and_( + ProductVersion.product_id == id_, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + ) + ).scalar_one() + skus = ( + db.execute( + select(SkuVersion) + .join(SkuVersion.sku) + .options(contains_eager(SkuVersion.sku)) + .where( + and_( + StockKeepingUnit.product_id == id_, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + ) + ) + .scalars() + .all() + ) + return product_info(version, skus) -def product_info(product: Product) -> schemas.Product: +def product_info(version: ProductVersion, skus: Sequence[SkuVersion]) -> schemas.Product: return schemas.Product( - id_=product.id, - code=product.code, - name=product.name, - description=product.description, - fraction_units=product.fraction_units, + id_=version.product_id, + handle=version.handle, + name=version.name, + description=version.description, + fraction_units=version.fraction_units, skus=[ schemas.StockKeepingUnit( id_=sku.id, @@ -320,27 +585,27 @@ def product_info(product: Product) -> schemas.Product: cost_price=sku.cost_price, sale_price=sku.sale_price, ) - for sku in product.skus + for sku in skus ], - is_active=product.is_active, - is_fixture=product.is_fixture, - is_purchased=product.is_purchased, - is_sold=product.is_sold, - product_group=schemas.ProductGroupLink(id_=product.product_group.id, name=product.product_group.name), - allergen=product.allergen, - protein=product.protein, - carbohydrate=product.carbohydrate, - total_sugar=product.total_sugar, - added_sugar=product.added_sugar, - total_fat=product.total_fat, - saturated_fat=product.saturated_fat, - trans_fat=product.trans_fat, - cholestrol=product.cholestrol, - sodium=product.sodium, - msnf=product.msnf, - other_solids=product.other_solids, - total_solids=product.total_solids, - water=product.water, + is_fixture=version.product.is_fixture, + is_purchased=version.is_purchased, + is_sold=version.is_sold, + is_active=True, + product_group=schemas.ProductGroupLink(id_=version.product_group.id, name=version.product_group.name), + allergen=version.allergen, + protein=version.protein, + carbohydrate=version.carbohydrate, + total_sugar=version.total_sugar, + added_sugar=version.added_sugar, + total_fat=version.total_fat, + saturated_fat=version.saturated_fat, + trans_fat=version.trans_fat, + cholestrol=version.cholestrol, + sodium=version.sodium, + msnf=version.msnf, + other_solids=version.other_solids, + total_solids=version.total_solids, + water=version.water, ) @@ -350,10 +615,10 @@ def product_blank() -> schemas.ProductBlank: description="", fraction_units="", skus=[], - is_active=True, is_purchased=True, is_sold=False, is_fixture=False, + is_active=True, allergen="", protein=Decimal(0), carbohydrate=Decimal(0), @@ -371,10 +636,9 @@ def product_blank() -> schemas.ProductBlank: ) -def get_rc_price(id_: uuid.UUID, d: str | None, v: uuid.UUID | None, db: Session) -> Decimal | None: - if d is None or v is None: +def get_rc_price(id_: uuid.UUID, date_: date, v: uuid.UUID | None, db: Session) -> Decimal | None: + if v is None: return None - date_ = datetime.strptime(d, "%d-%b-%Y") contracts = select(RateContract.id).where( RateContract.vendor_id == v, RateContract.valid_from <= date_, RateContract.valid_till >= date_ ) diff --git a/brewman/brewman/routers/purchase.py b/brewman/brewman/routers/purchase.py index c3d9951c..d29192c4 100644 --- a/brewman/brewman/routers/purchase.py +++ b/brewman/brewman/routers/purchase.py @@ -4,13 +4,15 @@ from datetime import UTC, date, datetime from decimal import Decimal from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status -from sqlalchemy import distinct, func, or_, select +from sqlalchemy import and_, distinct, func, or_, select, update from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session import brewman.schemas.input as schema_in import brewman.schemas.voucher as output +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion from brewman.routers.tag import save_tags, update_tags from ..core.security import get_current_active_user as get_user @@ -73,10 +75,21 @@ def save_route( def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: - product_accounts = ( - select(Product.account_id) - .join(Product.skus) - .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + product_ids = select(func.distinct(StockKeepingUnit.product_id)).where( + StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]) + ) + product_accounts = select(func.distinct(ProductVersion.account_id)).where( + and_( + ProductVersion.product_id.in_(product_ids), + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= data.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= data.date_, + ), + ) ) account_types: list[int] = list( db.execute( @@ -138,7 +151,23 @@ def save_inventories(voucher: Voucher, vendor_id: uuid.UUID, inventories: list[I tax=item.tax, discount=item.discount, ) - sku.cost_price = item.rate + db.execute( + update(SkuVersion) + .where( + and_( + SkuVersion.sku_id == sku.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + .values(cost_price=item.rate) + ) voucher.inventories.append(inventory) db.add(inventory) @@ -148,11 +177,22 @@ def save_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) -> journals: dict[uuid.UUID, Journal] = {} amount = Decimal(0) for i_item in voucher.inventories: + product_id = select(StockKeepingUnit.product_id).where(StockKeepingUnit.id == i_item.batch.sku.id) + product_account = select(ProductVersion.account_id).where( + and_( + ProductVersion.product_id == product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) account_id, cc_id = db.execute( - select(AccountBase.id, AccountBase.cost_centre_id) - .join(AccountBase.products) - .join(Product.skus) - .where(StockKeepingUnit.id == i_item.batch.sku.id) + select(AccountBase.id, AccountBase.cost_centre_id).where(AccountBase.id == product_account) ).one() amount += round(i_item.amount, 2) if account_id in journals: @@ -205,11 +245,23 @@ def update_route( def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() - product_accounts = ( - select(Product.account_id) - .join(Product.skus) - .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + product_ids = select(func.distinct(StockKeepingUnit.product_id)).where( + StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]) ) + product_accounts = select(func.distinct(ProductVersion.account_id)).where( + and_( + ProductVersion.product_id.in_(product_ids), + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= data.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= data.date_, + ), + ) + ) + account_types: list[int] = list( db.execute( select(distinct(AccountBase.type_id)).where( @@ -247,16 +299,32 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, inventories: list[I (idx for (idx, d) in enumerate(inventories) if d.id_ == item.id and d.batch.sku.id_ == item.batch.sku_id), None, ) + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == item.batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() if index is not None: new_inventory = inventories.pop(index) sku: StockKeepingUnit = db.execute( select(StockKeepingUnit).where(StockKeepingUnit.id == item.batch.sku.id) ).scalar_one() + rc_price = rate_contract_price(sku.id, vendor_id, voucher.date_, db) if batch_has_older_vouchers(item.batch_id, voucher.date_, voucher.id, db): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{item.batch.sku.product.name} has older vouchers", + detail=f"{product_name} has older vouchers", ) if rc_price is not None and rc_price != new_inventory.rate: raise HTTPException( @@ -280,7 +348,23 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, inventories: list[I item.batch.discount = new_inventory.discount item.tax = new_inventory.tax item.batch.tax = new_inventory.tax - sku.cost_price = new_inventory.rate + db.execute( + update(SkuVersion) + .where( + and_( + SkuVersion.sku_id == sku.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + .values(cost_price=new_inventory.rate) + ) db.flush() fix_single_batch_prices(item.batch_id, db) else: @@ -290,7 +374,7 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, inventories: list[I if has_been_issued > 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{item.batch.sku.product.name} has been issued, it cannot be deleted", + detail=f"{product_name} has been issued, it cannot be deleted", ) else: db.delete(item.batch) @@ -304,9 +388,21 @@ def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) - journals: dict[uuid.UUID, Journal] = {} amount = Decimal(0) for i_item in voucher.inventories: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) account_id, cc_id = db.execute( select(AccountBase.id, AccountBase.cost_centre_id) .join(AccountBase.products) + .join(Product, onclause=product_version_onclause) .join(Product.skus) .where(StockKeepingUnit.id == i_item.batch.sku.id) ).one() diff --git a/brewman/brewman/routers/purchase_return.py b/brewman/brewman/routers/purchase_return.py index 4cdfbb6d..31a55c1e 100644 --- a/brewman/brewman/routers/purchase_return.py +++ b/brewman/brewman/routers/purchase_return.py @@ -4,13 +4,15 @@ from datetime import UTC, datetime from decimal import Decimal from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status -from sqlalchemy import distinct, or_, select +from sqlalchemy import and_, distinct, or_, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, contains_eager import brewman.schemas.input as schema_in import brewman.schemas.voucher as output +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion from brewman.routers.tag import save_tags, update_tags from ..core.security import get_current_active_user as get_user @@ -70,9 +72,25 @@ def save_route( def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= data.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= data.date_, + ), + ) product_accounts = ( - select(Product.account_id) + select(ProductVersion.account_id) + .join(Product, onclause=product_version_onclause) .join(Product.skus) + .options( + contains_eager(ProductVersion.product).contains_eager(Product.versions), + contains_eager(Product.skus), + ) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) ) account_types: list[int] = list( @@ -107,17 +125,46 @@ def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: def save_inventories(voucher: Voucher, inventories: list[InventorySchema], db: Session) -> None: for d_item in inventories: batch = db.execute(select(Batch).where(Batch.id == d_item.batch.id_)).scalar_one() + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() + product_unit = db.execute( + select(SkuVersion.units).where( + and_( + SkuVersion.sku_id == batch.sku_id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() if d_item.quantity > batch.quantity_remaining: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{batch.sku.product.name} ({batch.sku.units}) stock is {batch.quantity_remaining}", + detail=f"{product_name} ({product_unit}) stock is {batch.quantity_remaining}", ) if batch.name > voucher.date_: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{batch.sku.product.name} ({batch.sku.units}) " - f"was purchased on {batch.name.strftime('%d-%b-%Y')}", + detail=f"{product_name} ({product_unit}) was purchased on {batch.name.strftime('%d-%b-%Y')}", ) batch.quantity_remaining -= d_item.quantity @@ -138,9 +185,21 @@ def save_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) -> journals: dict[uuid.UUID, Journal] = {} amount = Decimal(0) for v_item in voucher.inventories: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) account_id, cc_id = db.execute( select(AccountBase.id, AccountBase.cost_centre_id) .join(AccountBase.products) + .join(Product, onclause=product_version_onclause) .join(Product.skus) .where(StockKeepingUnit.id == v_item.batch.sku.id) ).one() @@ -199,11 +258,28 @@ def update_route( def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= data.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= data.date_, + ), + ) product_accounts = ( - select(Product.account_id) + select(ProductVersion.account_id) + .join(Product, onclause=product_version_onclause) .join(Product.skus) + .options( + contains_eager(ProductVersion.product).contains_eager(Product.versions), + contains_eager(Product.skus), + ) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) ) + account_types: list[int] = list( db.execute( select(distinct(AccountBase.type_id)).where( @@ -240,13 +316,42 @@ def update_inventory(voucher: Voucher, new_inventories: list[InventorySchema], d index = next( (idx for (idx, d) in enumerate(new_inventories) if d.id_ == item.id and d.batch.id_ == item.batch.id), None ) + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() + product_unit = db.execute( + select(SkuVersion.units).where( + and_( + SkuVersion.sku_id == batch.sku_id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() if index is not None: new_inventory = new_inventories.pop(index) if new_inventory.quantity > batch_quantity: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Maximum quantity available for " - f"{batch.sku.product.name} ({batch.sku.units}) is {batch_quantity}", + detail=f"Maximum quantity available for {product_name} ({product_unit}) is {batch_quantity}", ) if batch.name > voucher.date_: raise HTTPException( @@ -270,9 +375,21 @@ def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) - journals: dict[uuid.UUID, Journal] = {} amount = Decimal(0) for v_item in voucher.inventories: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) account_id, cc_id = db.execute( select(AccountBase.id, AccountBase.cost_centre_id) .join(AccountBase.products) + .join(Product, onclause=product_version_onclause) .join(Product.skus) .where(StockKeepingUnit.id == v_item.batch.sku.id) ).one() diff --git a/brewman/brewman/routers/rate_contract.py b/brewman/brewman/routers/rate_contract.py index 36400e2c..c018b5d8 100644 --- a/brewman/brewman/routers/rate_contract.py +++ b/brewman/brewman/routers/rate_contract.py @@ -1,12 +1,17 @@ import uuid +from datetime import date from typing import Any from fastapi import APIRouter, HTTPException, Request, Security, status -from sqlalchemy import delete, select +from sqlalchemy import and_, delete, or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion +from brewman.models.stock_keeping_unit import StockKeepingUnit + from ..core.security import get_current_active_user as get_user from ..core.session import ( get_date, @@ -50,7 +55,7 @@ async def save( set_date(data.date_, request.session) set_period(data.valid_from, data.valid_till, request.session) db.commit() - return rate_contract_info(item) + return rate_contract_info(item, db) except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -85,7 +90,7 @@ async def update_route( set_date(data.date_, request.session) set_period(data.valid_from, data.valid_till, request.session) db.commit() - return rate_contract_info(item) + return rate_contract_info(item, db) except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -134,7 +139,7 @@ async def show_list( ) -> list[RateContractSchema]: with SessionFuture() as db: return [ - rate_contract_info(item) + rate_contract_info(item, db) for item in db.execute(select(RateContract).order_by(RateContract.date_)).scalars().all() ] @@ -146,10 +151,10 @@ async def show_id( ) -> RateContractSchema: with SessionFuture() as db: item: RateContract = db.execute(select(RateContract).where(RateContract.id == id_)).scalar_one() - return rate_contract_info(item) + return rate_contract_info(item, db) -def rate_contract_info(item: RateContract) -> RateContractSchema: +def rate_contract_info(item: RateContract, db: Session) -> RateContractSchema: return RateContractSchema( id_=item.id, date_=item.date_, @@ -163,9 +168,7 @@ def rate_contract_info(item: RateContract) -> RateContractSchema: items=[ RateContractItemSchema( id_=i.id, - sku=ProductLink( - id_=i.sku_id, name=f"{i.sku.product.name} ({i.sku.units})" if i.sku.units else i.sku.product.name - ), + sku=ProductLink(id_=i.sku_id, name=get_product_name(i.sku, item.date_, db)), price=i.price, ) for i in item.items @@ -173,6 +176,41 @@ def rate_contract_info(item: RateContract) -> RateContractSchema: ) +def get_product_name(sku: StockKeepingUnit, date_: date, db: Session) -> str: + product_unit = db.execute( + select(SkuVersion.units).where( + and_( + SkuVersion.sku_id == sku.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + ) + ).scalar_one() + + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + ) + ).scalar_one() + return f"{product_name} ({product_unit})" + + def rate_contract_blank(session: dict[str, Any]) -> RateContractBlank: return RateContractBlank( date_=get_date(session), # type: ignore[arg-type] diff --git a/brewman/brewman/routers/rebase.py b/brewman/brewman/routers/rebase.py index c2b7d139..b98c0b8f 100644 --- a/brewman/brewman/routers/rebase.py +++ b/brewman/brewman/routers/rebase.py @@ -108,7 +108,7 @@ def save_starred(date_: date, db: Session) -> list[uuid.UUID]: def opening_accounts(date_: date, user_id: uuid.UUID, db: Session) -> Voucher: - running_total = 0 + running_total = Decimal(0) sum_func = func.sum(Journal.signed_amount) query = db.execute( select(AccountBase, sum_func) @@ -134,7 +134,7 @@ def opening_accounts(date_: date, user_id: uuid.UUID, db: Session) -> Voucher: ) for account, amount in query: amount = round(Decimal(amount), 2) - if account.type_.balance_sheet and amount != 0: + if account.type_.balance_sheet and amount != Decimal(0): running_total += amount journal = Journal( amount=abs(amount), diff --git a/brewman/brewman/routers/recipe.py b/brewman/brewman/routers/recipe.py index 5d418f48..100f1cd9 100644 --- a/brewman/brewman/routers/recipe.py +++ b/brewman/brewman/routers/recipe.py @@ -12,13 +12,16 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Security, status from fastapi.responses import FileResponse, StreamingResponse from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, NamedStyle, PatternFill, Side -from sqlalchemy import delete, func, select +from sqlalchemy import and_, delete, func, or_, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, contains_eager, joinedload +from sqlalchemy.orm import Session, aliased, contains_eager, joinedload import brewman.schemas.recipe as schemas import brewman.schemas.recipe_item as rischemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.price import Price @@ -207,11 +210,44 @@ async def show_list( user: UserToken = Depends(get_user), ) -> list[schemas.Recipe]: with SessionFuture() as db: + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Recipe.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Recipe.date_, + ), + ) + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Recipe.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Recipe.date_, + ), + ) list_: Sequence[Recipe] = ( db.execute( select(Recipe) - .options(joinedload(Recipe.sku, innerjoin=True).joinedload(StockKeepingUnit.product, innerjoin=True)) - .order_by(Recipe.sku_id) + .join(Recipe.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) + .options( + contains_eager(Recipe.sku) + .contains_eager(StockKeepingUnit.versions) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.product_group) + ) + .order_by(ProductVersion.name) ) .scalars() .all() @@ -219,7 +255,7 @@ async def show_list( return [ schemas.Recipe( id_=item.id, - sku=schemas.ProductLink(id_=item.sku.id, name=item.sku.product.name), + sku=schemas.ProductLink(id_=item.sku.id, name=item.sku.product.versions[0].name), date_=item.date_, source=item.source, instructions=item.instructions, @@ -227,8 +263,8 @@ async def show_list( plating=item.plating, recipe_yield=item.recipe_yield, notes=item.notes, - product_group_id=item.sku.product.product_group_id, - units=item.sku.units, + product_group_id=item.sku.product.versions[0].product_group_id, + units=item.sku.versions[0].units, items=[], ) for item in list_ @@ -267,7 +303,7 @@ def show_pdf( @router.get("/xlsx", response_class=StreamingResponse) def get_report( - p: uuid.UUID | None = None, + p: uuid.UUID, t: uuid.UUID | None = None, ) -> StreamingResponse: with SessionFuture() as db: @@ -281,24 +317,79 @@ def get_report( .scalars() .all() ) - prices = [(i.product.name, i.product.fraction_units, i.price) for i in pq] + prices = [(i.product.versions[-1].name, i.product.versions[-1].fraction_units, i.price) for i in pq] list_: Sequence[Recipe] = [] with SessionFuture() as db: + RecipeProductVersion = aliased(ProductVersion, name="recipe_product_version") + ItemProductVersion = aliased(ProductVersion, name="item_product_version") + CurrentSkuVersion = aliased(SkuVersion, name="current_sku_version") + + # The ON clauses for date validity (as in your original) + sku_version_onclause = and_( + CurrentSkuVersion.sku_id == StockKeepingUnit.id, + or_( + CurrentSkuVersion.valid_from == None, # noqa: E711 + CurrentSkuVersion.valid_from <= Recipe.date_, + ), + or_( + CurrentSkuVersion.valid_till == None, # noqa: E711 + CurrentSkuVersion.valid_till >= Recipe.date_, + ), + ) + + recipe_product_version_onclause = and_( + RecipeProductVersion.product_id == StockKeepingUnit.product_id, + or_( + RecipeProductVersion.valid_from == None, # noqa: E711 + RecipeProductVersion.valid_from <= Recipe.date_, + ), + or_( + RecipeProductVersion.valid_till == None, # noqa: E711 + RecipeProductVersion.valid_till >= Recipe.date_, + ), + ) + + item_product_version_onclause = and_( + ItemProductVersion.product_id == RecipeItem.product_id, + or_( + ItemProductVersion.valid_from == None, # noqa: E711 + ItemProductVersion.valid_from <= Recipe.date_, + ), + or_( + ItemProductVersion.valid_till == None, # noqa: E711 + ItemProductVersion.valid_till >= Recipe.date_, + ), + ) + q = ( select(Recipe) + .join(Recipe.items) + .join(RecipeItem.product) + .join(ItemProductVersion, item_product_version_onclause) .join(Recipe.sku) + .join(CurrentSkuVersion, sku_version_onclause) .join(StockKeepingUnit.product) - .join(Product.product_group) + .join(RecipeProductVersion, recipe_product_version_onclause) + .join(RecipeProductVersion.product_group) .options( - joinedload(Recipe.items, innerjoin=True).joinedload(RecipeItem.product, innerjoin=True), - contains_eager(Recipe.sku, StockKeepingUnit.product, Product.product_group), + # Eagerly load ingredients and their product & product_version + contains_eager(Recipe.items) + .contains_eager(RecipeItem.product) + .contains_eager(Product.versions, alias=ItemProductVersion), + # Eagerly load sku + contains_eager(Recipe.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions, alias=RecipeProductVersion) + .contains_eager(RecipeProductVersion.product_group), + # SkuVersion for the SKU (if needed) + contains_eager(Recipe.sku).contains_eager(StockKeepingUnit.versions, alias=CurrentSkuVersion), ) ) if p is not None: - q = q.where(Recipe.sku, StockKeepingUnit.product, Product.product_group_id == p) + q = q.where(RecipeProductVersion.product_group_id == p) list_ = db.execute(q).unique().scalars().all() - e = excel(prices, sorted(list_, key=lambda r: r.sku.product.name)) + e = excel(prices, sorted(list_, key=lambda r: r.sku.product.versions[0].name)) e.seek(0) headers = {"Content-Disposition": "attachment; filename = recipe.xlsx"} @@ -316,21 +407,21 @@ def excel(prices: list[tuple[str, str, Decimal]], recipes: list[Recipe]) -> Byte wb.active.cell(row=i, column=2, value=p[1]) wb.active.cell(row=i, column=3, value=p[2]) - pgs = set([x.sku.product.product_group.name for x in recipes]) + pgs = set([x.sku.product.versions[0].product_group.name for x in recipes]) for pg in pgs: wb.create_sheet(pg) rows = defaultdict(lambda: 1) register_styles(wb) for recipe in recipes: - ws = wb[recipe.sku.product.product_group.name] - row = rows[recipe.sku.product.product_group.name] + ws = wb[recipe.sku.product.versions[0].product_group.name] + row = rows[recipe.sku.product.versions[0].product_group.name] print(row) ings = len(recipe.items) ing_from = row + 2 ing_till = ing_from + ings - 1 - ws.cell(row=row, column=1, value=recipe.sku.product.name).style = "recipe_name" + ws.cell(row=row, column=1, value=recipe.sku.product.versions[0].name).style = "recipe_name" # ws.cell(row=row, column=3, value=f"Yeild = {recipe.recipe_yield} {recipe.sku.units}") - ws.cell(row=row, column=2, value=recipe.sku.units).style = "recipe_unit" + ws.cell(row=row, column=2, value=recipe.sku.versions[0].units).style = "recipe_unit" ws.cell(row=row, column=3, value=recipe.recipe_yield).style = "recipe_name" ws.cell(row=row, column=4).style = "recipe_name" ws.cell(row=row, column=5, value=f"=SUM(E{ing_from}:E{ing_till})").style = "recipe_name" @@ -342,12 +433,12 @@ def excel(prices: list[tuple[str, str, Decimal]], recipes: list[Recipe]) -> Byte ws.cell(row=row, column=5, value="Amount").style = "header" for item in recipe.items: row += 1 - ws.cell(row=row, column=1, value=item.product.name).style = "ing" - ws.cell(row=row, column=2, value=item.product.fraction_units).style = "unit" + ws.cell(row=row, column=1, value=item.product.versions[0].name).style = "ing" + ws.cell(row=row, column=2, value=item.product.versions[0].fraction_units).style = "unit" ws.cell(row=row, column=3, value=item.quantity).style = "ing" ws.cell(row=row, column=4, value="=VLOOKUP(A:A,'Rate List'!A:C,3,0)").style = "ing" ws.cell(row=row, column=5, value=f"=C{row}*D{row}").style = "ing" - rows[recipe.sku.product.product_group.name] = row + 1 + rows[recipe.sku.product.versions[0].product_group.name] = row + 1 virtual_workbook = BytesIO() wb.save(virtual_workbook) return virtual_workbook @@ -466,7 +557,7 @@ def recipe_info(recipe: Recipe) -> schemas.Recipe: id_=recipe.id, sku=schemas.ProductLink( id_=recipe.sku_id, - name=f"{recipe.sku.product.name} ({recipe.sku.units})", + name=f"{recipe.sku.product.versions[0].name} ({recipe.sku.versions[0].units})", ), date_=recipe.date_, source=recipe.source, @@ -480,7 +571,7 @@ def recipe_info(recipe: Recipe) -> schemas.Recipe: id_=item.id, product=schemas.ProductLink( id_=item.product.id, - name=f"{item.product.name} ({item.product.fraction_units})", + name=f"{item.product.versions[0].name} ({item.product.versions[0].fraction_units})", ), quantity=round(item.quantity, 2), description=item.description, diff --git a/brewman/brewman/routers/reports/closing_stock.py b/brewman/brewman/routers/reports/closing_stock.py index df971de2..411b17c8 100644 --- a/brewman/brewman/routers/reports/closing_stock.py +++ b/brewman/brewman/routers/reports/closing_stock.py @@ -4,12 +4,16 @@ from datetime import date, datetime from decimal import Decimal from fastapi import APIRouter, HTTPException, Request, Security, status +from sqlalchemy import and_ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql.expression import delete, distinct, func, or_, select, update import brewman.schemas.closing_stock as schemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_finish_date, get_start_date, set_date, set_period from ...db.session import SessionFuture @@ -138,23 +142,58 @@ def full_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> schemas. def build_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> list[schemas.ClosingStockItem]: amount_sum = func.sum(Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax)).label("amount") quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") - query = db.execute( - select(StockKeepingUnit, quantity_sum, amount_sum) - .join(StockKeepingUnit.product) - .join(Product.product_group) - .join(StockKeepingUnit.batches) - .join(Batch.inventories) - .join(Inventory.voucher) - .join(Voucher.journals) - .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) - .where( - Voucher.date_ <= date_, - Journal.cost_centre_id == cost_centre_id, - or_(Voucher.voucher_type != VoucherType.CLOSING_STOCK, Voucher.date_ != date_), + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) + + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + query: list[tuple[SkuVersion, Decimal, Decimal]] = ( + db.execute( + select(SkuVersion, quantity_sum, amount_sum) + .select_from(Voucher) + .join(Voucher.journals) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.product_group) + ) + .where( + Voucher.date_ <= date_, + Journal.cost_centre_id == cost_centre_id, + or_(Voucher.voucher_type != VoucherType.CLOSING_STOCK, Voucher.date_ != date_), + ) + .group_by(StockKeepingUnit, Product, ProductVersion, ProductGroup, SkuVersion) # type: ignore + .order_by(ProductGroup.name, ProductVersion.name, SkuVersion.units) ) - .group_by(StockKeepingUnit, Product, ProductGroup) # type: ignore - .order_by(ProductGroup.name, Product.name, StockKeepingUnit.units) - ).all() + .unique() + .all() + ) physical_list = ( db.execute( @@ -179,16 +218,18 @@ def build_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> list[sc ).all() body = [] - for sku, quantity, amount in query: + for sku_version, quantity, amount in query: if quantity != 0 and amount != 0: - id_ = next((p.id for p in physical_list if p.sku_id == sku.id), None) - physical = next((p.quantity for p in physical_list if p.sku_id == sku.id), quantity) - cc = next((schemas.CostCentreLink(id_=c) for (c, s) in ccs if s == sku.id), None) + id_ = next((p.id for p in physical_list if p.sku_id == sku_version.sku_id), None) + physical = next((p.quantity for p in physical_list if p.sku_id == sku_version.sku_id), quantity) + cc = next((schemas.CostCentreLink(id_=c) for (c, s) in ccs if s == sku_version.sku_id), None) body.append( schemas.ClosingStockItem( id_=id_, - product=schemas.ProductLink(id_=sku.id, name=f"{sku.product.name} ({sku.units})"), - group=sku.product.product_group.name, + product=schemas.ProductLink( + id_=sku_version.sku_id, name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})" + ), + group=sku_version.sku.product.versions[0].product_group.name, quantity=quantity, amount=amount, physical=physical, @@ -289,8 +330,27 @@ def save_cs(data: schemas.ClosingStock, db: Session) -> None: def save(date_: date, items: list[schemas.ClosingStockItem], user: UserToken, db: Session) -> Voucher: - product_accounts: list[uuid.UUID] = list( # type: ignore - select(Product.account_id).join(Product.skus).where(StockKeepingUnit.id.in_([i.product.id_ for i in items])) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) + + product_accounts = ( + db.execute( + select(ProductVersion.account_id) + .join(Product, onclause=product_version_onclause) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.product.id_ for i in items])) + ) + .scalars() + .all() ) account_types: list[int] = list( db.execute(select(distinct(AccountBase.type_id)).where(AccountBase.id.in_(product_accounts))).scalars().all() diff --git a/brewman/brewman/routers/reports/mozimo_daily_register.py b/brewman/brewman/routers/reports/mozimo_daily_register.py index a4ac892f..c86de0da 100644 --- a/brewman/brewman/routers/reports/mozimo_daily_register.py +++ b/brewman/brewman/routers/reports/mozimo_daily_register.py @@ -4,13 +4,16 @@ from datetime import UTC, date, datetime from decimal import Decimal from fastapi import APIRouter, HTTPException, Request, Security, status -from sqlalchemy import desc, func, or_ +from sqlalchemy import and_, desc, func, or_ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from sqlalchemy.sql.expression import select import brewman.schemas.mozimo_daily_register as schemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_date, set_date from ...db.session import SessionFuture @@ -42,30 +45,55 @@ def show_data( body = build_report(d, db) set_date(date_, request.session) return schemas.MozimoDailyRegister( - date=d, + date_=d, body=body, ) def build_report(date_: date, db: Session) -> list[schemas.MozimoDailyRegisterItem]: body = [] + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= date_, + ), + ) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= date_, + ), + ) products = ( db.execute( - select(StockKeepingUnit) - .join(StockKeepingUnit.product) - .where(Product.product_group_id == uuid.UUID("dad46805-f577-4e5b-8073-9b788e0173fc")) # Menu items - .order_by(Product.name, StockKeepingUnit.units) + select(ProductVersion.name, SkuVersion.units, SkuVersion.sku_id) + .select_from(Product) + .join(ProductVersion, onclause=product_version_onclause) + .join(Product.skus) + .join(SkuVersion, onclause=sku_version_onclause) + .where(ProductVersion.product_group_id == uuid.UUID("dad46805-f577-4e5b-8073-9b788e0173fc")) # Menu items + .order_by(ProductVersion.name, SkuVersion.units) ) .scalars() .all() ) - for sku in products: - ob = opening_balance(sku.id, date_, db) + for name, units, sku_id in products: + ob = opening_balance(sku_id, date_, db) item = ( db.execute( select(MozimoStockRegister).where( - MozimoStockRegister.sku_id == sku.id, + MozimoStockRegister.sku_id == sku_id, MozimoStockRegister.date_ == date_, ) ) @@ -75,7 +103,7 @@ def build_report(date_: date, db: Session) -> list[schemas.MozimoDailyRegisterIt body.append( schemas.MozimoDailyRegisterItem( id_=None if item is None else item.id_, - product=schemas.ProductLink(id_=sku.id, name=f"{sku.product.name} ({sku.units})"), + product=schemas.ProductLink(id_=sku_id, name=f"{name} ({units})"), opening=ob, received=Decimal(0) if item is None else item.received, sale=Decimal(0) if item is None else item.sale, @@ -107,14 +135,18 @@ def opening_balance(product_id: uuid.UUID, start_date: date, db: Session) -> Dec .scalars() .one_or_none() ) - physical = ((opening_physical.display or 0) + (opening_physical.ageing or 0)) if opening_physical is not None else 0 + physical = ( + ((opening_physical.display or Decimal(0)) + (opening_physical.ageing or Decimal(0))) + if opening_physical is not None + else Decimal(0) + ) query_ = select(func.sum(MozimoStockRegister.received - MozimoStockRegister.sale - MozimoStockRegister.nc)).where( MozimoStockRegister.sku_id == product_id, MozimoStockRegister.date_ < start_date, ) if opening_physical is not None: query_ = query_.where(MozimoStockRegister.date_ > opening_physical.date_) - calculated = db.execute(query_).scalar() or 0 + calculated = db.execute(query_).scalar() or Decimal(0) return physical + calculated diff --git a/brewman/brewman/routers/reports/mozimo_product_register.py b/brewman/brewman/routers/reports/mozimo_product_register.py index df65528f..b100b9e0 100644 --- a/brewman/brewman/routers/reports/mozimo_product_register.py +++ b/brewman/brewman/routers/reports/mozimo_product_register.py @@ -53,7 +53,7 @@ def show_data( return schemas.MozimoProductRegister( start_date=start_date, finish_date=finish_date, - product=schemas.ProductLink(id_=sku.id, name=f"{sku.product.name} ({sku.units})"), + product=schemas.ProductLink(id_=sku.id, name=f"{sku.product.versions[-1].name} ({sku.versions[-1].units})"), body=body, ) @@ -91,7 +91,7 @@ def build_report( ) if item is not None: if item.ageing is not None or item.display is not None: - closing = (item.ageing or 0) + (item.display or 0) + closing = (item.ageing or Decimal(0)) + (item.display or Decimal(0)) else: closing = ob + item.received - item.sale - item.nc ob = closing # Setting the cb as ob for next iteration @@ -135,7 +135,7 @@ def save_route( ob = opening_balance(data.product.id_, data.start_date, db) for item in data.body: if item.ageing is not None or item.display is not None: - closing = (item.ageing or 0) + (item.display or 0) + closing = (item.ageing or Decimal(0)) + (item.display or Decimal(0)) else: closing = ob + item.received - item.sale - item.nc ob = closing # Setting the cb as ob for next iteration diff --git a/brewman/brewman/routers/reports/mozimo_sale_upload.py b/brewman/brewman/routers/reports/mozimo_sale_upload.py new file mode 100644 index 00000000..d5d6fd6c --- /dev/null +++ b/brewman/brewman/routers/reports/mozimo_sale_upload.py @@ -0,0 +1,47 @@ +import csv +import io +import uuid + +from datetime import date, datetime, time, timedelta +from io import StringIO + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +import brewman.schemas.fingerprint as schemas + +from ...core.security import get_user +from ...db.session import SessionFuture +from ...models.employee import Employee +from ...models.fingerprint import Fingerprint +from ...schemas.user import UserToken + + +router = APIRouter() + + +def fp(file_data: StringIO, employees: dict[int, uuid.UUID]) -> list[schemas.Fingerprint]: + fingerprints: list[schemas.Fingerprint] = [] + reader = csv.reader(file_data, delimiter="\t") + header = next(reader) + employee_column = 2 + date_column = len(header) - 1 + date_format = "%Y-%m-%d %H:%M:%S" + for row in reader: + try: + employee_code = int(row[employee_column]) # EnNo + date_ = datetime.strptime(row[date_column].replace("/", "-"), date_format) + if employee_code in employees: + fingerprints.append( + schemas.Fingerprint( + id_=uuid.uuid4(), + employee_id=employees[employee_code], + date_=date_, + ) + ) + except ValueError: + continue + return fingerprints diff --git a/brewman/brewman/routers/reports/non_contract_purchase.py b/brewman/brewman/routers/reports/non_contract_purchase.py index 0349d7aa..f06e02aa 100644 --- a/brewman/brewman/routers/reports/non_contract_purchase.py +++ b/brewman/brewman/routers/reports/non_contract_purchase.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Security -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import Session, contains_eager, joinedload import brewman.schemas.non_contract_purchase as schemas @@ -10,7 +10,9 @@ from brewman.models.batch import Batch from brewman.models.inventory import Inventory from brewman.models.journal import Journal from brewman.models.product import Product +from brewman.models.product_version import ProductVersion from brewman.models.rate_contract import RateContract +from brewman.models.sku_version import SkuVersion from brewman.models.stock_keeping_unit import StockKeepingUnit from brewman.models.voucher import Voucher from brewman.models.voucher_type import VoucherType @@ -46,13 +48,41 @@ def report(db: Session) -> list[schemas.NonContractPurchase]: .all() ) list_: list[schemas.NonContractPurchase] = [] + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) + for rc in rcs: for item in rc.items: invs = db.execute( - select(Voucher.id, Voucher.date_, Inventory.id, Inventory.rate) - .join(Inventory.batch) - .join(Inventory.voucher) + select(Voucher.id, Voucher.date_, Inventory.rate, ProductVersion.name, SkuVersion.units) + .select_from(Voucher) .join(Voucher.journals) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) .where( Batch.sku_id == item.sku_id, Voucher.date_ >= rc.valid_from, @@ -62,24 +92,19 @@ def report(db: Session) -> list[schemas.NonContractPurchase]: Inventory.rate != item.price, ) ).all() - for inv in invs: - p, u = db.execute( - select(Product.name, StockKeepingUnit.units) - .join(Product.skus) - .where(StockKeepingUnit.id == item.sku_id) - ).one() + for voucher_id, date_, rate, name, units in invs: list_.append( schemas.NonContractPurchase( - date_=inv.date_, + date_=date_, vendor=rc.vendor.name, - product=f"{p} ({u})", + product=f"{name} ({units})", url=[ "/", "purchase", - str(inv.id), + str(voucher_id), ], contract_price=item.price, - purchase_price=inv.rate, + purchase_price=rate, ) ) return list_ diff --git a/brewman/brewman/routers/reports/product_ledger.py b/brewman/brewman/routers/reports/product_ledger.py index 115c2bee..1cb43742 100644 --- a/brewman/brewman/routers/reports/product_ledger.py +++ b/brewman/brewman/routers/reports/product_ledger.py @@ -4,11 +4,14 @@ from datetime import date, datetime from decimal import Decimal from fastapi import APIRouter, Request, Security -from sqlalchemy.orm import Session, joinedload +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql.expression import func, select import brewman.schemas.product_ledger as schemas +from brewman.models.sku_version import SkuVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_finish_date, get_start_date, set_period from ...db.session import SessionFuture @@ -61,7 +64,7 @@ def show_data( return schemas.ProductLedger( start_date=start_date, finish_date=finish_date, - product=schemas.ProductLink(id_=product.id, name=product.name), + product=schemas.ProductLink(id_=product.id, name=product.versions[-1].name), body=body, ) @@ -72,16 +75,31 @@ def build_report( running_total_q, running_total_a, opening = opening_balance(product_id, start_date, db) body = opening - query = db.execute( - select(Voucher, Inventory, Journal, StockKeepingUnit) + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + query: list[tuple[Voucher, Inventory, Journal, SkuVersion]] = db.execute( # type: ignore[assignment] + select(Voucher, Inventory, Journal, SkuVersion) + .select_from(Voucher) .join(Voucher.journals) + .join(Journal.account) + .join(Journal.cost_centre) .join(Voucher.inventories) .join(Inventory.batch) .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) .join(StockKeepingUnit.product) .options( - joinedload(Journal.account, innerjoin=True), - joinedload(Journal.cost_centre, innerjoin=True), + contains_eager(Voucher.journals).contains_eager(Journal.account), + contains_eager(Voucher.journals).contains_eager(Journal.cost_centre), ) .where( StockKeepingUnit.product_id == product_id, @@ -141,16 +159,30 @@ def build_report( def opening_balance( product_id: uuid.UUID, start_date: date, db: Session ) -> tuple[Decimal, Decimal, list[schemas.ProductLedgerItem]]: + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + row = db.execute( select( - StockKeepingUnit.units, + SkuVersion.units, func.sum(Inventory.quantity * Journal.debit).label("quantity"), func.sum(Inventory.amount * Journal.debit).label("amount"), ) + .select_from(Voucher) + .join(Voucher.journals) + .join(Voucher.inventories) .join(Inventory.batch) .join(Batch.sku) - .join(Inventory.voucher) - .join(Voucher.journals) + .join(SkuVersion, onclause=sku_version_onclause) .where( Voucher.id == Inventory.voucher_id, Voucher.id == Journal.voucher_id, @@ -158,7 +190,7 @@ def opening_balance( Journal.cost_centre_id == CostCentre.cost_centre_purchase(), Voucher.date_ < start_date, ) - .group_by(StockKeepingUnit.units) + .group_by(SkuVersion.units) ).all() quantity: Decimal = sum(r.quantity for r in row) diff --git a/brewman/brewman/routers/reports/purchase_entries.py b/brewman/brewman/routers/reports/purchase_entries.py index 41636776..86da643c 100644 --- a/brewman/brewman/routers/reports/purchase_entries.py +++ b/brewman/brewman/routers/reports/purchase_entries.py @@ -1,11 +1,14 @@ from datetime import date, datetime from fastapi import APIRouter, Request, Security -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import Session, contains_eager import brewman.schemas.purchase_entries as schemas +from brewman.models.product import Product +from brewman.models.product_version import ProductVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_finish_date, get_start_date, set_period from ...db.session import SessionFuture @@ -50,6 +53,17 @@ def report_data( def build_report(start_date: date, finish_date: date, db: Session) -> list[schemas.PurchaseEntriesItem]: body = [] + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) query: list[Voucher] = list( db.execute( select(Voucher) @@ -59,12 +73,14 @@ def build_report(start_date: date, finish_date: date, db: Session) -> list[schem .join(Inventory.batch) .join(Batch.sku) .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) .options( contains_eager(Voucher.journals).contains_eager(Journal.account), contains_eager(Voucher.inventories) .contains_eager(Inventory.batch) .contains_eager(Batch.sku) - .contains_eager(StockKeepingUnit.product), + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions), ) .where( Voucher.date_ >= start_date, @@ -89,7 +105,7 @@ def build_report(start_date: date, finish_date: date, db: Session) -> list[schem voucher.voucher_type.name.replace("_", "-").lower(), str(voucher.id), ], - product=item.batch.sku.product.name, + product=item.batch.sku.product.versions[0].name, quantity=item.quantity, rate=item.rate, tax=item.tax, diff --git a/brewman/brewman/routers/reports/purchases.py b/brewman/brewman/routers/reports/purchases.py index 230ef04d..e3936341 100644 --- a/brewman/brewman/routers/reports/purchases.py +++ b/brewman/brewman/routers/reports/purchases.py @@ -2,12 +2,15 @@ from datetime import datetime from decimal import Decimal from fastapi import APIRouter, Request, Security -from sqlalchemy import not_ +from sqlalchemy import and_, not_, or_ from sqlalchemy.orm import Session from sqlalchemy.sql.expression import desc, func, select import brewman.schemas.purchases as schemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_finish_date, get_start_date, set_period from ...db.session import SessionFuture @@ -62,25 +65,51 @@ def build_report( body = [] quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") amount_sum = func.sum(Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax)).label("amount") - query = db.execute( - select(Product, quantity_sum, amount_sum) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + query: list[tuple[ProductVersion, Decimal, Decimal]] = db.execute( + select(ProductVersion, quantity_sum, amount_sum) + .select_from(Voucher) .join(Voucher.journals) .join(Voucher.inventories) .join(Inventory.batch) .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) .where( Voucher.date_ >= datetime.strptime(start_date, "%d-%b-%Y"), Voucher.date_ <= datetime.strptime(finish_date, "%d-%b-%Y"), not_(Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK])), Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(Product) # type: ignore + .group_by(ProductVersion) # type: ignore .order_by(desc(amount_sum)) ).all() total_amount = Decimal(0) for product, quantity, amount in query: - rate = amount / quantity if quantity != 0 else 0 + rate = amount / quantity if quantity != 0 else Decimal(0) total_amount += amount row = schemas.PurchasesItem( name=product.name, diff --git a/brewman/brewman/routers/reports/raw_material_cost.py b/brewman/brewman/routers/reports/raw_material_cost.py index d94f4a4b..55e7b743 100644 --- a/brewman/brewman/routers/reports/raw_material_cost.py +++ b/brewman/brewman/routers/reports/raw_material_cost.py @@ -4,11 +4,15 @@ from datetime import date, datetime from decimal import Decimal from fastapi import APIRouter, Request, Security +from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from sqlalchemy.sql.expression import case, func, select import brewman.schemas.raw_material_cost as schemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_finish_date, get_start_date, set_period from ...db.session import SessionFuture @@ -89,7 +93,7 @@ def build_report( sum_issue = func.sum(case((AccountBase.type_id == 2, Journal.signed_amount), else_=0)).label("issue") sum_sale = func.sum(case((AccountBase.type_id == 3, Journal.signed_amount * -1), else_=0)).label("sale") - query = db.execute( + query: list[tuple[CostCentre, Decimal, Decimal]] = db.execute( select(CostCentre, sum_issue, sum_sale) .join(CostCentre.journals) .join(Journal.voucher) @@ -109,7 +113,7 @@ def build_report( for cost_centre, issue, sale in query: issues += issue sales += sale - rmc = 0 if sale == 0 else issue / sale + rmc = Decimal(0) if sale == 0 else issue / sale body.append( schemas.RawMaterialCostItem( name=cost_centre.name, @@ -121,7 +125,7 @@ def build_report( ) ) - rmc = 0 if sales == 0 else issues / sales + rmc = Decimal(0) if sales == 0 else issues / sales return body, schemas.RawMaterialCostItem(name="Total", issue=issues, sale=sales, rmc=rmc, order=0) @@ -132,21 +136,46 @@ def build_report_id( sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net") sum_gross = func.sum(Inventory.amount * Journal.debit).label("gross") - query = db.execute( - select(Product, ProductGroup, sum_quantity, sum_net, sum_gross) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + query: list[tuple[ProductVersion, ProductGroup, Decimal, Decimal, Decimal]] = db.execute( + select(ProductVersion, ProductGroup, sum_quantity, sum_net, sum_gross) + .select_from(Voucher) + .join(Voucher.journals) + .join(Voucher.inventories) .join(Inventory.batch) .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) .join(StockKeepingUnit.product) - .join(Product.product_group) - .join(Inventory.voucher) - .join(Voucher.journals) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) .where( Voucher.date_ >= start_date, Voucher.date_ <= finish_date, Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]), Journal.cost_centre_id == cost_centre_id, ) - .group_by(Product, ProductGroup, Journal.debit, ProductGroup.name) # type: ignore + .group_by(ProductVersion, Product, ProductGroup, Journal.debit, ProductGroup.name) # type: ignore .order_by(ProductGroup.name, sum_net.desc()) ).all() diff --git a/brewman/brewman/routers/reports/stock_movement.py b/brewman/brewman/routers/reports/stock_movement.py index 0dcf6b68..47fb54aa 100644 --- a/brewman/brewman/routers/reports/stock_movement.py +++ b/brewman/brewman/routers/reports/stock_movement.py @@ -2,12 +2,15 @@ from datetime import date, datetime from decimal import Decimal from fastapi import APIRouter, Request, Security -from sqlalchemy import not_ +from sqlalchemy import and_, not_, or_ from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql.expression import func, select import brewman.schemas.stock_movement as schemas +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ...core.security import get_current_active_user as get_user from ...core.session import get_finish_date, get_start_date, set_period from ...db.session import SessionFuture @@ -54,90 +57,137 @@ def report_data( def build_stock_movement(start_date: date, finish_date: date, db: Session) -> list[schemas.StockMovementItem]: dict_ = {} quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") - openings = db.execute( - select(StockKeepingUnit, quantity_sum) - .join(StockKeepingUnit.product) - .join(Product.product_group) - .join(StockKeepingUnit.batches) - .join(Batch.inventories) - .join(Inventory.voucher) + product_version_onclause = and_( + ProductVersion.product_id == Product.id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= Voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= Voucher.date_, + ), + ) + + sku_version_onclause = and_( + SkuVersion.sku_id == StockKeepingUnit.id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= Voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= Voucher.date_, + ), + ) + openings: list[tuple[SkuVersion, Decimal]] = db.execute( + select(SkuVersion, quantity_sum) + .select_from(Voucher) .join(Voucher.journals) - .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.product_group) + ) .where(Voucher.date_ < start_date, Journal.cost_centre_id == CostCentre.cost_centre_purchase()) - .group_by(StockKeepingUnit, Product, ProductGroup) # type: ignore + .group_by(StockKeepingUnit, Product, ProductVersion, ProductGroup, SkuVersion) # type: ignore ).all() - for sku, quantity in openings: - dict_[sku.id] = schemas.StockMovementItem( - id_=sku.id, - name=f"{sku.product.name} ({sku.units})", - group=sku.product.product_group.name, + for sku_version, quantity in openings: + dict_[sku_version.sku_id] = schemas.StockMovementItem( + id_=sku_version.id, + name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})", + group=sku_version.sku.product.versions[0].product_group.name, opening=Decimal(round(quantity, 2)), purchase=Decimal(0), issue=Decimal(0), closing=Decimal(0), - url=["/", "product-ledger", str(sku.product.id)], + url=["/", "product-ledger", str(sku_version.sku.product_id)], ) - purchases = db.execute( - select(StockKeepingUnit, quantity_sum) - .join(StockKeepingUnit.product) - .join(Product.product_group) - .join(StockKeepingUnit.batches) - .join(Batch.inventories) - .join(Inventory.voucher) + purchases: list[tuple[SkuVersion, Decimal]] = db.execute( + select(SkuVersion, quantity_sum) + .select_from(Voucher) .join(Voucher.journals) - .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.product_group) + ) .where( Voucher.date_ >= start_date, Voucher.date_ <= finish_date, not_(Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK])), Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(StockKeepingUnit, Product, ProductGroup) # type: ignore + .group_by(StockKeepingUnit, Product, ProductVersion, ProductGroup, SkuVersion) # type: ignore ).all() - for sku, quantity in purchases: - if sku.id in dict_: - dict_[sku.id].purchase = Decimal(round(quantity, 2)) + for sku_version, quantity in purchases: + if sku_version.sku_id in dict_: + dict_[sku_version.sku_id].purchase = Decimal(round(quantity, 2)) else: - dict_[sku.id] = schemas.StockMovementItem( - id_=sku.id, - name=f"{sku.product.name} ({sku.units})", - group=sku.product.product_group.name, + dict_[sku_version.sku_id] = schemas.StockMovementItem( + id_=sku_version.id, + name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})", + group=sku_version.sku.product.versions[0].product_group.name, opening=Decimal(0), purchase=Decimal(round(quantity, 2)), issue=Decimal(0), closing=Decimal(0), - url=["/", "product-ledger", str(sku.product.id)], + url=["/", "product-ledger", str(sku_version.sku.product_id)], ) - issues = db.execute( - select(StockKeepingUnit, quantity_sum) - .join(StockKeepingUnit.product) - .join(Product.product_group) - .join(StockKeepingUnit.batches) - .join(Batch.inventories) - .join(Inventory.voucher) + issues: list[tuple[SkuVersion, Decimal]] = db.execute( + select(SkuVersion, quantity_sum) + .select_from(Voucher) .join(Voucher.journals) - .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(SkuVersion, onclause=sku_version_onclause) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=product_version_onclause) + .join(ProductVersion.product_group) + .options( + contains_eager(SkuVersion.sku) + .contains_eager(StockKeepingUnit.product) + .contains_eager(Product.versions) + .contains_eager(ProductVersion.product_group) + ) .where( Voucher.date_ >= start_date, Voucher.date_ <= finish_date, Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]), Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(StockKeepingUnit, Product, ProductGroup) # type: ignore + .group_by(StockKeepingUnit, Product, ProductVersion, ProductGroup, SkuVersion) # type: ignore ).all() - for sku, quantity in issues: - if sku.id in dict_: - dict_[sku.id].issue = Decimal(round(quantity * -1, 2)) + for sku_version, quantity in issues: + if sku_version.sku_id in dict_: + dict_[sku_version.sku_id].issue = Decimal(round(quantity * -1, 2)) else: - dict_[sku.id] = schemas.StockMovementItem( - id_=sku.id, - name=f"{sku.product.name} ({sku.units})", - group=sku.product.product_group.name, + dict_[sku_version.sku_id] = schemas.StockMovementItem( + id_=sku_version.id, + name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})", + group=sku_version.sku.product.versions[0].product_group.name, opening=Decimal(0), purchase=Decimal(0), issue=Decimal(round(quantity * -1, 2)), closing=Decimal(0), - url=["/", "product-ledger", str(sku.product.id)], + url=["/", "product-ledger", str(sku_version.sku.product_id)], ) list_ = [value for key, value in dict_.items()] diff --git a/brewman/brewman/routers/voucher.py b/brewman/brewman/routers/voucher.py index 9c69f9c8..e0ed1ed5 100644 --- a/brewman/brewman/routers/voucher.py +++ b/brewman/brewman/routers/voucher.py @@ -11,6 +11,9 @@ from sqlalchemy.orm import Session import brewman.schemas.voucher as output +from brewman.models.product_version import ProductVersion +from brewman.models.sku_version import SkuVersion + from ..core.security import get_current_active_user as get_user from ..core.session import get_first_day from ..db.session import SessionFuture @@ -137,10 +140,26 @@ def delete_voucher( else: for i_item in voucher.inventories: if i_item.batch.quantity_remaining < i_item.quantity: + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == i_item.batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() + raise HTTPException( status_code=status.HTTP_423_LOCKED, detail=f"Only {i_item.batch.quantity_remaining}" - f" of {i_item.batch.sku.product.name} remaining.\n" + f" of {product_name} remaining.\n" f"So it cannot be deleted", ) i_item.batch.quantity_remaining -= i_item.quantity @@ -152,9 +171,24 @@ def delete_voucher( ) ).scalar_one() if uses > 0: + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == i_item.batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() raise HTTPException( status_code=status.HTTP_423_LOCKED, - detail=f"{i_item.batch.sku.product.name} has been issued and cannot be deleted", + detail=f"{product_name} has been issued and cannot be deleted", ) batches_to_delete.append(i_item.batch) elif voucher.voucher_type == VoucherType.PURCHASE_RETURN: @@ -260,8 +294,38 @@ def voucher_info(voucher: Voucher, db: Session) -> output.Voucher: if len(json_voucher.incentives) > 0: json_voucher.incentive = next(x.amount for x in voucher.journals if x.account_id == Account.incentive_id()) for inventory in voucher.inventories: + product_name = db.execute( + select(ProductVersion.name).where( + and_( + ProductVersion.product_id == inventory.batch.sku.product_id, + or_( + ProductVersion.valid_from == None, # noqa: E711 + ProductVersion.valid_from <= voucher.date_, + ), + or_( + ProductVersion.valid_till == None, # noqa: E711 + ProductVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() + product_unit = db.execute( + select(SkuVersion.units).where( + and_( + SkuVersion.sku_id == inventory.batch.sku_id, + or_( + SkuVersion.valid_from == None, # noqa: E711 + SkuVersion.valid_from <= voucher.date_, + ), + or_( + SkuVersion.valid_till == None, # noqa: E711 + SkuVersion.valid_till >= voucher.date_, + ), + ) + ) + ).scalar_one() text = ( - f"{inventory.batch.sku.product.name} ({inventory.batch.sku.units}) " + f"{product_name} ({product_unit}) " f"{inventory.batch.quantity_remaining:.2f}@{inventory.batch.rate:.2f} " f"from {inventory.batch.name.strftime('%d-%b-%Y')}" ) @@ -282,9 +346,7 @@ def voucher_info(voucher: Voucher, db: Session) -> output.Voucher: rate=inventory.batch.rate, sku=output.ProductLink( id_=inventory.batch.sku.id, - name=f"{inventory.batch.sku.product.name} ({inventory.batch.sku.units})" - if inventory.batch.sku.units - else inventory.batch.sku.product.name, + name=f"{product_name} ({product_unit})" if product_unit else product_name, ), ), ) diff --git a/brewman/brewman/schemas/mozimo_daily_register.py b/brewman/brewman/schemas/mozimo_daily_register.py index 1cff8140..b69ce81c 100644 --- a/brewman/brewman/schemas/mozimo_daily_register.py +++ b/brewman/brewman/schemas/mozimo_daily_register.py @@ -41,7 +41,7 @@ class MozimoDailyRegisterItem(BaseModel): class MozimoDailyRegister(BaseModel): - date_: date + date_: date | str body: list[MozimoDailyRegisterItem] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/brewman/brewman/schemas/mozimo_product_register.py b/brewman/brewman/schemas/mozimo_product_register.py index f8c276f9..06873456 100644 --- a/brewman/brewman/schemas/mozimo_product_register.py +++ b/brewman/brewman/schemas/mozimo_product_register.py @@ -52,8 +52,8 @@ class MozimoProductRegisterItem(BaseModel): class MozimoProductRegister(BaseModel): - start_date: date | None = None - finish_date: date | None = None + start_date: date | str + finish_date: date | str product: ProductLink | None = None body: list[MozimoProductRegisterItem] model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/brewman/brewman/schemas/product.py b/brewman/brewman/schemas/product.py index b78c1685..bbc7c2f2 100644 --- a/brewman/brewman/schemas/product.py +++ b/brewman/brewman/schemas/product.py @@ -46,7 +46,7 @@ class ProductIn(BaseModel): class Product(ProductIn): id_: uuid.UUID - code: int + handle: str is_fixture: bool diff --git a/brewman/brewman/schemas/stock_keeping_unit.py b/brewman/brewman/schemas/stock_keeping_unit.py index 0881d1a3..a7f8b01f 100644 --- a/brewman/brewman/schemas/stock_keeping_unit.py +++ b/brewman/brewman/schemas/stock_keeping_unit.py @@ -1,5 +1,7 @@ import uuid +from decimal import Decimal + from pydantic import BaseModel, ConfigDict, Field from . import Daf, to_camel @@ -8,8 +10,8 @@ from . import Daf, to_camel class StockKeepingUnit(BaseModel): id_: uuid.UUID | None = None units: str = Field(..., min_length=1) - fraction: Daf = Field(ge=1, default=1) - product_yield: Daf = Field(gt=0, le=1, default=1) - cost_price: Daf = Field(ge=0, default=0) - sale_price: Daf = Field(ge=0, default=0) + fraction: Daf = Field(ge=Decimal(1), default=Decimal(1)) + product_yield: Daf = Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1)) + cost_price: Daf = Field(ge=Decimal(0), default=Decimal(0)) + sale_price: Daf = Field(ge=Decimal(0), default=Decimal(0)) model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) diff --git a/brewman/brewman/tests/model.py b/brewman/brewman/tests/model.py new file mode 100644 index 00000000..e5400985 --- /dev/null +++ b/brewman/brewman/tests/model.py @@ -0,0 +1,23 @@ +""" +Simple script to import all SQLAlchemy models and catch import-time errors. +Place this file in your tests/ directory. +""" + +import sys + +from pathlib import Path + + +# Adjust this if your models are in a different location +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +try: + # Adjust the import to match where your models are + from brewman.db import base # e.g., from app import models # noqa: F401 + + print("✅ All models imported successfully.") +except Exception as e: + print("❌ Error importing models!") + print(e) + sys.exit(1) diff --git a/deploy.sh b/deploy.sh index a8f751f5..bae8b8e2 100755 --- a/deploy.sh +++ b/deploy.sh @@ -12,25 +12,12 @@ else ./version_bump.sh fi -# Download the package.json for caching -curl --silent 'https://git.tanshu.com/tanshu/brewman/raw/tag/latest/overlord/package.json' \ - | sed 's/\"version\": \"[0-9\.]*\"/"version": "0.0.0"/g' \ - > "$parent_path/docker/app/package.json" - -# Download the package.json for caching -curl --silent 'https://git.tanshu.com/tanshu/brewman/raw/tag/latest/brewman/pyproject.toml' \ - | sed 's/version = \"[0-9\.]*\"/version = "0.0.0"/g' \ - > "$parent_path/docker/app/pyproject.toml" - - -cd "$parent_path/docker/app" || exit -docker build --tag brewman:latest . +make build-production if [ 1 -eq "$#" ] then docker tag brewman:latest "$1" else echo "No version bump" fi -cd "$parent_path/docker" || exit -docker save brewman:latest | bzip2 | pv | ssh gondor 'bunzip2 | sudo docker load' +cd "$parent_path/ansible" || exit ansible-playbook --inventory hosts playbook.yml diff --git a/overlord/src/app/tag-dialog/tag-dialog.component.ts b/overlord/src/app/tag-dialog/tag-dialog.component.ts index eee05d39..18770f3c 100644 --- a/overlord/src/app/tag-dialog/tag-dialog.component.ts +++ b/overlord/src/app/tag-dialog/tag-dialog.component.ts @@ -1,11 +1,7 @@ import { CdkScrollable } from '@angular/cdk/scrolling'; -import { AsyncPipe, CurrencyPipe } from '@angular/common'; import { Component, Inject, OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete'; import { MatButton } from '@angular/material/button'; -import { MatOption } from '@angular/material/core'; -import { MatDatepicker } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogRef, @@ -14,12 +10,10 @@ import { MatDialogActions, MatDialogClose, } from '@angular/material/dialog'; -import { MatFormField, MatLabel, MatHint, MatPrefix } from '@angular/material/form-field'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; -import { MatSelect } from '@angular/material/select'; import { Tag } from '../tag/tag'; -import { AccountingPipe } from '../shared/accounting.pipe'; import { MatCheckbox } from '@angular/material/checkbox'; import { LedgerItem } from '../ledger/ledger-item'; import { TagList } from './tag-list'; @@ -32,24 +26,14 @@ import { MatSnackBar } from '@angular/material/snack-bar'; styleUrls: ['./tag-dialog.component.css'], standalone: true, imports: [ - AccountingPipe, - AsyncPipe, - CurrencyPipe, CdkScrollable, MatCheckbox, MatDialogTitle, MatDialogContent, ReactiveFormsModule, MatFormField, - MatSelect, - MatDatepicker, - MatOption, MatLabel, MatInput, - MatAutocompleteTrigger, - MatHint, - MatAutocomplete, - MatPrefix, MatDialogActions, MatButton, MatDialogClose,