Changed the build system. Now I am targeting multi arch. Added Product Version and SkuVersion

This commit is contained in:
tanshu committed 2025-06-25 06:22:44 +00:00
1 parent 0d2dea4eff
commit 05b395af00
48 files changed
+2379 -561

No files matched your search

+13 -13
View File
@@ -1,22 +1,22 @@
TITLE="Brewman" TITLE=Hops n Grains
HOST=0.0.0.0 HOST=0.0.0.0
PORT=80 PORT=9998
LOG_LEVEL=WARN LOG_LEVEL=WARNING
DEBUG=true DEBUG=true
SQLALCHEMY_DATABASE_URI= SQLALCHEMY_DATABASE_URI=postgresql+psycopg://postgres:123456@10.1.1.1:5432/hops
MODULE_NAME=brewman.main MODULE_NAME=brewman.main
PROJECT_NAME=brewman PROJECT_NAME=brewman
POSTGRES_SERVER= POSTGRES_SERVER=localhost
POSTGRES_USER= POSTGRES_USER=postgres
POSTGRES_PASSWORD= POSTGRES_PASSWORD=123456
POSTGRES_DB= POSTGRES_DB=hops
# openssl rand -hex 32 PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAX5qCMOidCSToKo2oe6eNii+Jg9qKiQDz2+JD/r3gQAs=\n-----END PUBLIC KEY-----
SECRET_KEY= PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHlhlEP7acCnOWq6VUWJN/q2NU2Mwo7eEtMo1em6luy7\n-----END PRIVATE KEY-----
# openssl rand -hex 5 # openssl rand -hex 5
MIDDLEWARE_SECRET_KEY= MIDDLEWARE_SECRET_KEY=c982367648
ALGORITHM=HS256 ALGORITHM=EdDSA
JWT_TOKEN_EXPIRE_MINUTES=30 JWT_TOKEN_EXPIRE_MINUTES=30
ALEMBIC_LOG_LEVEL=INFO ALEMBIC_LOG_LEVEL=INFO
ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARN ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARNING
+65
View File
@@ -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 <docker@tanshu.com>"
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"]
+7
View File
@@ -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
+24
View File
@@ -19,3 +19,27 @@ rm -rf `poetry env info -p`
```sh ```sh
poetry install 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)
@@ -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")
+16 -2
View File
@@ -1,14 +1,16 @@
import secrets import secrets
from dotenv import load_dotenv 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 from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
# openssl rand -hex 32 # openssl rand -hex 32
TITLE: str = "" 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) MIDDLEWARE_SECRET_KEY: str = secrets.token_urlsafe(5)
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
JWT_TOKEN_EXPIRE_MINUTES: int = 30 JWT_TOKEN_EXPIRE_MINUTES: int = 30
@@ -22,6 +24,18 @@ class Settings(BaseSettings):
POSTGRES_DB: str = "" POSTGRES_DB: str = ""
SQLALCHEMY_DATABASE_URI: str | None = None 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") @model_validator(mode="after")
def assemble_db_connection(self) -> "Settings": def assemble_db_connection(self) -> "Settings":
if isinstance(self.SQLALCHEMY_DATABASE_URI, str): if isinstance(self.SQLALCHEMY_DATABASE_URI, str):
+5 -5
View File
@@ -3,10 +3,10 @@ import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import jwt
from fastapi import Depends, HTTPException, Security, status from fastapi import Depends, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from jose import jwt
from jose.exceptions import ExpiredSignatureError
from jwt import PyJWTError from jwt import PyJWTError
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from sqlalchemy import select from sqlalchemy import select
@@ -37,7 +37,7 @@ def create_access_token(*, data: dict[str, Any], expires_delta: timedelta | None
to_encode = data.copy() to_encode = data.copy()
expire = datetime.now(UTC).replace(tzinfo=None) + (expires_delta or timedelta(minutes=15)) expire = datetime.now(UTC).replace(tzinfo=None) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire}) 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 return encoded_jwt
@@ -80,13 +80,13 @@ async def get_current_user(
headers={"WWW-Authenticate": authenticate_value}, headers={"WWW-Authenticate": authenticate_value},
) )
try: 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 "" username: str = payload.get("sub") or ""
if username is None: if username is None:
raise credentials_exception raise credentials_exception
token_scopes = payload.get("scopes", []) token_scopes = payload.get("scopes", [])
token_data = TokenData(scopes=token_scopes, username=username) token_data = TokenData(scopes=token_scopes, username=username)
except (PyJWTError, ValidationError, ExpiredSignatureError): except (PyJWTError, ValidationError):
raise credentials_exception raise credentials_exception
user = get_user( user = get_user(
username=token_data.username, username=token_data.username,
+1
View File
@@ -23,6 +23,7 @@ from ..models.permission import Permission # noqa: F401
from ..models.price import Price # noqa: F401 from ..models.price import Price # noqa: F401
from ..models.product import Product # noqa: F401 from ..models.product import Product # noqa: F401
from ..models.product_group import ProductGroup # 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 import RateContract # noqa: F401
from ..models.rate_contract_item import RateContractItem # noqa: F401 from ..models.rate_contract_item import RateContractItem # noqa: F401
from ..models.recipe import Recipe # noqa: F401 from ..models.recipe import Recipe # noqa: F401
+2 -12
View File
@@ -1,25 +1,15 @@
from __future__ import annotations
import uuid import uuid
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, relationship
from ..db.base_class import reg from ..db.base_class import reg
from .account_base import AccountBase from .account_base import AccountBase
if TYPE_CHECKING:
from .product import Product
@reg.mapped_as_dataclass(unsafe_hash=True) @reg.mapped_as_dataclass(unsafe_hash=True)
class Account(AccountBase): class Account(AccountBase):
__mapper_args__ = {"polymorphic_identity": ""} # type: ignore __mapper_args__ = {"polymorphic_identity": ""} # type: ignore
products: Mapped[list["Product"]] = relationship(
"Product", primaryjoin="Account.id==Product.account_id", back_populates="account"
)
def __init__( def __init__(
self, self,
name: str, name: str,
+2 -2
View File
@@ -13,7 +13,7 @@ from .account_type import AccountType
if TYPE_CHECKING: if TYPE_CHECKING:
from .cost_centre import CostCentre from .cost_centre import CostCentre
from .journal import Journal from .journal import Journal
from .product import Product from .product_version import ProductVersion
from .rate_contract import RateContract from .rate_contract import RateContract
@@ -42,7 +42,7 @@ class AccountBase:
journals: Mapped[list["Journal"]] = relationship("Journal", back_populates="account") journals: Mapped[list["Journal"]] = relationship("Journal", back_populates="account")
cost_centre: Mapped["CostCentre"] = relationship("CostCentre", back_populates="accounts") 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") rate_contracts: Mapped[list["RateContract"]] = relationship("RateContract", back_populates="vendor")
@@ -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)
@@ -27,21 +27,21 @@ class MozimoStockRegister:
sale: 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) nc: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False)
# The physical stock in the display area # 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 # 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) last_edit_date: Mapped[datetime] = mapped_column(DateTime(), nullable=False)
sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit") sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit")
def __init__( def __init__(
self, self,
date_: datetime.date, date_: date,
received: Decimal, received: Decimal,
sale: Decimal, sale: Decimal,
nc: Decimal, nc: Decimal,
display: Decimal, display: Decimal | None = None,
ageing: Decimal, ageing: Decimal | None = None,
sku_id: uuid.UUID | None = None, sku_id: uuid.UUID | None = None,
sku: StockKeepingUnit | None = None, sku: StockKeepingUnit | None = None,
id_: uuid.UUID | None = None, id_: uuid.UUID | None = None,
+6 -98
View File
@@ -1,119 +1,27 @@
import uuid import uuid
from decimal import Decimal
from typing import TYPE_CHECKING 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db.base_class import reg from ..db.base_class import reg
if TYPE_CHECKING: if TYPE_CHECKING:
from .account import Account from .product_version import ProductVersion
from .product_group import ProductGroup
from .stock_keeping_unit import StockKeepingUnit from .stock_keeping_unit import StockKeepingUnit
@reg.mapped_as_dataclass(unsafe_hash=True) @reg.mapped_as_dataclass(unsafe_hash=True)
class Product: class Product:
__tablename__ = "products" __tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()"))
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)
is_fixture: Mapped[bool] = mapped_column(Boolean, nullable=False) is_fixture: Mapped[bool] = mapped_column(Boolean, nullable=False)
is_purchased: Mapped[bool] = mapped_column(Boolean, nullable=False) versions: Mapped[list["ProductVersion"]] = relationship(back_populates="product")
is_sold: Mapped[bool] = mapped_column(Boolean, nullable=False)
skus: Mapped[list["StockKeepingUnit"]] = relationship("StockKeepingUnit", 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: if id_ is not None:
self.id = id_ self.id = id_
if is_fixture is not None: self.is_fixture = is_fixture if is_fixture is not None else False
self.is_fixture = is_fixture
@classmethod
def suspense(cls) -> uuid.UUID:
return uuid.UUID("aa79a643-9ddc-4790-ac7f-a41f9efb4c15")
+2 -2
View File
@@ -9,7 +9,7 @@ from ..db.base_class import reg
if TYPE_CHECKING: if TYPE_CHECKING:
from .product import Product from .product_version import ProductVersion
@reg.mapped_as_dataclass(unsafe_hash=True) @reg.mapped_as_dataclass(unsafe_hash=True)
@@ -22,7 +22,7 @@ class ProductGroup:
ice_cream: Mapped[bool] = mapped_column(Boolean, nullable=False) ice_cream: Mapped[bool] = mapped_column(Boolean, nullable=False)
is_fixture: 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__( def __init__(
self, self,
+150
View File
@@ -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
+81
View File
@@ -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
+4 -19
View File
@@ -2,10 +2,9 @@ from __future__ import annotations
import uuid import uuid
from decimal import Decimal
from typing import TYPE_CHECKING 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db.base_class import reg from ..db.base_class import reg
@@ -15,43 +14,29 @@ if TYPE_CHECKING:
from .batch import Batch from .batch import Batch
from .product import Product from .product import Product
from .recipe import Recipe from .recipe import Recipe
from .sku_version import SkuVersion
@reg.mapped_as_dataclass(unsafe_hash=True) @reg.mapped_as_dataclass(unsafe_hash=True)
class StockKeepingUnit: class StockKeepingUnit:
__tablename__ = "stock_keeping_units" __tablename__ = "stock_keeping_units"
__table_args__ = (UniqueConstraint("product_id", "units"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4) 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) product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), unique=True, 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: Mapped[Product] = relationship("Product", back_populates="skus") product: Mapped[Product] = relationship("Product", back_populates="skus")
batches: Mapped[list[Batch]] = relationship("Batch", back_populates="sku") batches: Mapped[list[Batch]] = relationship("Batch", back_populates="sku")
recipes: Mapped[list[Recipe]] = relationship("Recipe", back_populates="sku") recipes: Mapped[list[Recipe]] = relationship("Recipe", back_populates="sku")
versions: Mapped[list[SkuVersion]] = relationship(back_populates="sku")
def __init__( def __init__(
self, self,
units: str,
fraction: Decimal,
product_yield: Decimal,
cost_price: Decimal,
sale_price: Decimal,
product_id: uuid.UUID | None = None, product_id: uuid.UUID | None = None,
product: Product | None = None, product: Product | None = None,
id_: uuid.UUID | None = None, id_: uuid.UUID | None = None,
) -> None: ) -> None:
if product_id is not None: if product_id is not None:
self.product_id = product_id 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: if id_ is not None:
self.id = id_ self.id = id_
if product is not None: if product is not None:
+11 -1
View File
@@ -1,5 +1,5 @@
from collections.abc import Sequence from collections.abc import Sequence
from datetime import date, timedelta from datetime import UTC, date, datetime, timedelta
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -73,3 +73,13 @@ def get_lock_info(
return True, "Voucher allowed" return True, "Voucher allowed"
return False, "Default Fallthrough" 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()
)
+38 -6
View File
@@ -1,11 +1,14 @@
import datetime import datetime
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import and_, or_, select
from sqlalchemy.orm import contains_eager from sqlalchemy.orm import contains_eager
import brewman.schemas.batch as schemas 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 ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture from ..db.session import SessionFuture
from ..models.batch import Batch from ..models.batch import Batch
@@ -26,21 +29,50 @@ def batch_term(
date_ = datetime.datetime.strptime(d, "%d-%b-%Y") date_ = datetime.datetime.strptime(d, "%d-%b-%Y")
list_: list[schemas.Batch] = [] list_: list[schemas.Batch] = []
with SessionFuture() as db: 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 = ( query = (
select(Batch) select(Batch)
.join(Batch.sku) .join(Batch.sku)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(SkuVersion, onclause=sku_version_onclause)
.where(Batch.quantity_remaining > 0, Batch.name <= date_) .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: if q is not None:
for q_item in q.split(): for q_item in q.split():
query = query.where(Product.name.ilike(f"%{q_item}%")) query = query.where(ProductVersion.name.ilike(f"%{q_item}%"))
result = db.execute(query.order_by(Product.name).order_by(Batch.name)).scalars().all() result = db.execute(query.order_by(ProductVersion.name).order_by(Batch.name)).unique().scalars().all()
for item in result: for item in result:
product_name = item.sku.product.versions[0].name
product_units = item.sku.versions[0].units
text = ( 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')}" f"{item.rate:.2f} from {item.name.strftime('%d-%b-%Y')}"
) )
list_.append( list_.append(
@@ -53,7 +85,7 @@ def batch_term(
discount=round(item.discount, 5), discount=round(item.discount, 5),
sku=schemas.ProductLink( sku=schemas.ProductLink(
id_=item.sku.id, id_=item.sku.id,
name=f"{item.sku.product.name} ({item.sku.units})", name=f"{product_name} ({product_units})",
), ),
) )
) )
+31 -5
View File
@@ -3,11 +3,13 @@ import uuid
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Security 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 from sqlalchemy.orm import Session, contains_eager
import brewman.schemas.batch_integrity as schemas 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 ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture from ..db.session import SessionFuture
from ..models.batch import Batch from ..models.batch import Batch
@@ -37,17 +39,29 @@ def post_check_batch_integrity(
def negative_batches(db: Session) -> list[schemas.BatchIntegrity]: def negative_batches(db: Session) -> list[schemas.BatchIntegrity]:
inv_sum = func.sum(Inventory.quantity * Journal.debit).label("quantity") inv_sum = func.sum(Inventory.quantity * Journal.debit).label("quantity")
list_ = db.execute( product_version_onclause = and_(
select(Batch, Product.name, inv_sum) 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(Batch.sku)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(Batch.inventories) .join(Batch.inventories)
.join(Inventory.voucher) .join(Inventory.voucher)
.join(Voucher.journals) .join(Voucher.journals)
.where( .where(
Journal.cost_centre_id == CostCentre.cost_centre_purchase(), 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) .having(Batch.quantity_remaining != inv_sum)
).all() ).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]: 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_ = ( list_ = (
db.execute( db.execute(
select(Batch) select(Batch)
.join(Batch.sku) .join(Batch.sku)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(Batch.inventories) .join(Batch.inventories)
.join(Inventory.voucher) .join(Inventory.voucher)
.where(Voucher.date_ < Batch.name) .where(Voucher.date_ < Batch.name)
@@ -116,7 +142,7 @@ def batch_dates(db: Session) -> list[schemas.BatchIntegrity]:
issue.append( issue.append(
schemas.BatchIntegrity( schemas.BatchIntegrity(
id_=batch.id, id_=batch.id,
product=batch.sku.product.name, product=batch.sku.product.versions[0].name,
date_=batch.name, date_=batch.name,
showing=batch.quantity_remaining, showing=batch.quantity_remaining,
actual=Decimal(0), actual=Decimal(0),
+58 -14
View File
@@ -3,7 +3,7 @@ import uuid
from decimal import Decimal from decimal import Decimal
from fastapi import HTTPException, status 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -14,6 +14,7 @@ from brewman.models.inventory import Inventory
from brewman.models.journal import Journal from brewman.models.journal import Journal
from brewman.models.period import Period from brewman.models.period import Period
from brewman.models.price import Price from brewman.models.price import Price
from brewman.models.sku_version import SkuVersion
from brewman.models.voucher import Voucher from brewman.models.voucher import Voucher
from brewman.models.voucher_type import VoucherType 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]: def get_issue_prices(period: Period, products: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]:
sum_quantity = func.sum( sum_quantity = func.sum(Inventory.quantity * SkuVersion.fraction * SkuVersion.product_yield * Journal.debit).label(
Inventory.quantity * StockKeepingUnit.fraction * StockKeepingUnit.product_yield * Journal.debit "quantity"
).label("quantity") )
sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net") 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] = {} d: dict[uuid.UUID, Decimal] = {}
query = db.execute( query = db.execute(
select(StockKeepingUnit.product_id, sum_net / sum_quantity) select(StockKeepingUnit.product_id, issue_price)
.select_from(Inventory)
.join(Inventory.batch) .join(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(SkuVersion, onclause=sku_version_onclause)
.join(Inventory.voucher) .join(Inventory.voucher)
.join(Voucher.journals) .join(Voucher.journals)
.where( .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]: def get_purchase_prices(period: Period, req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]:
sum_quantity = func.sum( sum_quantity = func.sum(Inventory.quantity * SkuVersion.fraction * SkuVersion.product_yield * Journal.debit).label(
Inventory.quantity * StockKeepingUnit.fraction * StockKeepingUnit.product_yield * Journal.debit "quantity"
).label("quantity") )
sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net") 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] = {} d: dict[uuid.UUID, Decimal] = {}
query = db.execute( query = db.execute(
select(StockKeepingUnit.product_id, sum_net / sum_quantity) select(StockKeepingUnit.product_id, purchase_price)
.select_from(Inventory)
.join(Inventory.batch) .join(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(SkuVersion, onclause=sku_version_onclause)
.join(Inventory.voucher) .join(Inventory.voucher)
.join(Voucher.journals) .join(Voucher.journals)
.where( .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]: 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] = {} d: dict[uuid.UUID, Decimal] = {}
query = db.execute( query = db.execute(
select( select(
StockKeepingUnit.product_id, StockKeepingUnit.product_id,
StockKeepingUnit.cost_price / (StockKeepingUnit.fraction * StockKeepingUnit.product_yield), rest_price,
).where(StockKeepingUnit.product_id.in_(req)) )
.join(SkuVersion, onclause=sku_version_onclause)
.where(StockKeepingUnit.product_id.in_(req))
).all() ).all()
for id, amount in query: for id, amount in query:
d[id] = amount d[id] = amount
@@ -127,6 +169,8 @@ def calculate_recipes(recipes: set[uuid.UUID], prices: dict[uuid.UUID, Decimal],
.all() .all()
) )
for item in items: 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 prices[item.sku.product_id] = cost
recipes.remove(item.sku.product_id) recipes.remove(item.sku.product_id)
@@ -48,7 +48,7 @@ def employee_attendance_report(
finish_date = datetime.strptime(f or get_finish_date(request.session), "%d-%b-%Y").date() 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 start_date = employee.joining_date if employee.joining_date > start_date else start_date
finish_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( info = schemas.EmployeeAttendance(
start_date=start_date, start_date=start_date,
+1 -1
View File
@@ -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]: def get_prints(employee_id: uuid.UUID, date_: date, db: Session) -> tuple[str, str, bool]:
prints = ( prints = list(
db.execute( db.execute(
select(Fingerprint) select(Fingerprint)
.where( .where(
+104 -11
View File
@@ -4,13 +4,16 @@ from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status 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.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.input as schema_in
import brewman.schemas.voucher as output 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.security import get_current_active_user as get_user
from ..core.session import get_date, set_date from ..core.session import get_date, set_date
from ..db.session import SessionFuture 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]: 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 = ( product_accounts = (
select(Product.account_id) select(ProductVersion.account_id)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .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])) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]))
) )
account_types: list[int] = list( account_types: list[int] = list(
@@ -116,16 +135,45 @@ def save_inventories(
amount: Decimal = Decimal(0) amount: Decimal = Decimal(0)
for data_item in inventories: for data_item in inventories:
batch = db.execute(select(Batch).where(Batch.id == data_item.batch.id_)).scalar_one() 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: if batch_consumed and data_item.quantity > batch.quantity_remaining:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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_: if batch.name > voucher.date_:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{batch.sku.product.name} ({batch.sku.units}) " detail=f"{product_name} ({product_unit}) was purchased on {batch.name.strftime('%d-%b-%Y')}",
f"was purchased on {batch.name.strftime('%d-%b-%Y')}",
) )
if batch_consumed is None: if batch_consumed is None:
pass pass
@@ -205,9 +253,25 @@ def update_voucher(
id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session
) -> tuple[Voucher, bool | None]: ) -> tuple[Voucher, bool | None]:
voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() 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 = ( product_accounts = (
select(Product.account_id) select(ProductVersion.account_id)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .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])) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]))
) )
account_types: list[int] = list( account_types: list[int] = list(
@@ -280,18 +344,47 @@ def update_inventories(
index = next( index = next(
(idx for (idx, d) in enumerate(inventories) if d.id_ == item.id and d.batch.id_ == item.batch.id), None (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: if index is not None:
new_inventory = inventories.pop(index) new_inventory = inventories.pop(index)
if batch_consumed and new_inventory.quantity > batch_quantity: if batch_consumed and new_inventory.quantity > batch_quantity:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Maximum quantity available for " detail=f"Maximum quantity available for {product_name} ({product_unit}) is {batch_quantity}",
f"{batch.sku.product.name} ({batch.sku.units}) is {batch_quantity}",
) )
if item.batch.name > voucher.date_: if item.batch.name > voucher.date_:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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: if batch_consumed is None:
@@ -315,7 +408,7 @@ def update_inventories(
if batch_quantity < item.quantity: if batch_quantity < item.quantity:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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}", f" minimum quantity is {batch_quantity}",
) )
item.batch.quantity_remaining = batch_quantity item.batch.quantity_remaining = batch_quantity
+391 -127
View File
@@ -1,25 +1,30 @@
import uuid import uuid
from datetime import datetime from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Security, status 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.exc import SQLAlchemyError
from sqlalchemy.orm import Session, contains_eager from sqlalchemy.orm import Session, contains_eager
import brewman.schemas.product as schemas 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 ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture from ..db.session import SessionFuture
from ..models.account import Account from ..models.account import Account
from ..models.batch import Batch
from ..models.product import Product 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 import RateContract
from ..models.rate_contract_item import RateContractItem from ..models.rate_contract_item import RateContractItem
from ..models.stock_keeping_unit import StockKeepingUnit from ..models.stock_keeping_unit import StockKeepingUnit
from ..schemas.product_sku import ProductSku from ..schemas.product_sku import ProductSku
from ..schemas.user import UserToken from ..schemas.user import UserToken
from . import effective_date
router = APIRouter() router = APIRouter()
@@ -32,13 +37,17 @@ def save(
) -> schemas.Product: ) -> schemas.Product:
try: try:
with SessionFuture() as db: 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, name=data.name,
description=data.description, description=data.description,
fraction_units=data.fraction_units, fraction_units=data.fraction_units,
product_group_id=data.product_group.id_, product_group_id=data.product_group.id_,
account_id=Account.all_purchases(), account_id=Account.all_purchases(),
is_active=data.is_active,
is_purchased=data.is_purchased, is_purchased=data.is_purchased,
is_sold=data.is_sold, is_sold=data.is_sold,
allergen=data.allergen, allergen=data.allergen,
@@ -55,27 +64,34 @@ def save(
other_solids=data.other_solids, other_solids=data.other_solids,
total_solids=data.total_solids, total_solids=data.total_solids,
water=data.water, 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(version)
db.add(item)
if not len(data.skus): if not len(data.skus):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough stock keeping units.", detail="Not enough stock keeping units.",
) )
for sku in data.skus: for sku in data.skus:
db.add( s = StockKeepingUnit(product=product)
StockKeepingUnit( product.skus.append(s)
units=sku.units, db.add(s)
fraction=round(sku.fraction, 5), db.flush() # So sku.id is available
product_yield=round(sku.product_yield, 5), sver = SkuVersion(
cost_price=round(sku.cost_price, 2), units=sku.units,
sale_price=round(sku.sale_price, 2), fraction=round(sku.fraction, 5),
product=item, 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() 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: except SQLAlchemyError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -87,77 +103,184 @@ def save(
def update_route( def update_route(
id_: uuid.UUID, id_: uuid.UUID,
data: schemas.ProductIn, data: schemas.ProductIn,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]), user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.Product: ) -> schemas.Product:
try: try:
with SessionFuture() as db: with SessionFuture() as db:
item: Product = db.execute(select(Product).where(Product.id == id_)).scalar_one() version: ProductVersion = db.execute(
if item.is_fixture: 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( raise HTTPException(
status_code=status.HTTP_423_LOCKED, 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): if not len(data.skus):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Not enough stock keeping units.", detail="Not enough stock keeping units.",
) )
for i in range(len(item.skus), 0, -1): old_skus = (
sku = item.skus[i - 1] 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) index = next((idx for (idx, d) in enumerate(data.skus) if d.id_ == sku.id), None)
if index is not None: if index is not None:
new_data_sku = data.skus.pop(index) new_data_sku = data.skus.pop(index)
sku.units = new_data_sku.units sku_changed = (
sku.fraction = round(new_data_sku.fraction, 5) new_data_sku.units != sku.units
sku.product_yield = round(new_data_sku.product_yield, 5) or Decimal(new_data_sku.fraction).quantize(Decimal("0.00001")) != sku.fraction
sku.cost_price = round(new_data_sku.cost_price, 2) or Decimal(new_data_sku.product_yield).quantize(Decimal("0.00001")) != sku.product_yield
sku.sale_price = round(new_data_sku.sale_price, 2) or Decimal(new_data_sku.cost_price).quantize(Decimal("0.01")) != sku.cost_price
else: or Decimal(new_data_sku.sale_price).quantize(Decimal("0.01")) != sku.sale_price
count: int = db.execute(select(func.count()).where(Batch.sku_id == sku.id)).scalar_one() )
if count > 0:
raise HTTPException( print(f"SKU Changed: {sku_changed} for {sku.id}")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, if sku_changed:
detail=f"SKU '{sku.units}' has entries and cannot be deleted.", new_sku = (
sku
if sku.valid_from == date_
else SkuVersion(
sku_id=sku.sku_id,
valid_from=date_,
)
) )
item.skus.remove(sku) new_sku.units = new_data_sku.units
db.delete(sku) 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: 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, units=data_sku.units,
fraction=round(data_sku.fraction, 5), fraction=round(data_sku.fraction, 5),
product_yield=round(data_sku.product_yield, 5), product_yield=round(data_sku.product_yield, 5),
cost_price=round(data_sku.cost_price, 2), cost_price=round(data_sku.cost_price, 2),
sale_price=round(data_sku.sale_price, 2), sale_price=round(data_sku.sale_price, 2),
product=item, sku=new_s,
) )
db.add(new_sku) 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() db.commit()
return product_info(item) return product_info(version, current_skus)
except SQLAlchemyError as e: except SQLAlchemyError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -168,6 +291,7 @@ def update_route(
@router.delete("/{id_}", response_model=schemas.ProductBlank) @router.delete("/{id_}", response_model=schemas.ProductBlank)
def delete_route( def delete_route(
id_: uuid.UUID, id_: uuid.UUID,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]), user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.ProductBlank: ) -> schemas.ProductBlank:
with SessionFuture() as db: with SessionFuture() as db:
@@ -175,17 +299,49 @@ def delete_route(
if item.is_fixture: if item.is_fixture:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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() ).scalar_one()
if count > 0: skus = (
raise HTTPException( db.execute(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Product has entries and cannot be deleted." 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_)) .scalars()
db.execute(delete(Product).where(Product.id == id_)) .all()
)
valid_till = date_ - timedelta(days=1)
version.valid_till = valid_till
for sku in skus:
sku.valid_till = valid_till
db.commit() db.commit()
return product_blank() return product_blank()
@@ -198,20 +354,45 @@ def show_blank(
@router.get("/list", response_model=list[schemas.Product]) @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: 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 [ return [
product_info(item) product_info(item, [s.versions[0] for s in item.product.skus])
for item in db.execute( for item in db.execute(
select(Product) select(ProductVersion)
.join(Product.product_group) .join(ProductVersion.product)
.join(ProductVersion.product_group)
.join(ProductVersion.product)
.join(Product.skus) .join(Product.skus)
.order_by(desc(Product.is_active)) .join(SkuVersion, onclause=sku_version_onclause)
.order_by(Product.product_group_id) .where(
.order_by(Product.name) 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( .options(
contains_eager(Product.skus), contains_eager(ProductVersion.product, Product.skus, StockKeepingUnit.versions),
contains_eager(Product.product_group), contains_eager(ProductVersion.product_group),
) )
) )
.unique() .unique()
@@ -226,30 +407,64 @@ async def show_term_sku(
a: bool | None = None, # Active a: bool | None = None, # Active
p: bool | None = None, # Is Purchased? p: bool | None = None, # Is Purchased?
v: uuid.UUID | None = None, # Vendor v: uuid.UUID | None = None, # Vendor
d: str | None = None, # Date date_: date = Depends(effective_date), # Date
current_user: UserToken = Depends(get_user), current_user: UserToken = Depends(get_user),
) -> list[ProductSku]: ) -> list[ProductSku]:
list_ = [] list_ = []
with SessionFuture() as db: with SessionFuture() as db:
query_ = select(Product).join(Product.skus).options(contains_eager(Product.skus)) sku_version_onclause = and_(
if a is not None: SkuVersion.sku_id == StockKeepingUnit.id,
query_ = query_.filter(Product.is_active == a) 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: if p is not None:
query_ = query_.filter(Product.is_purchased == p) query_ = query_.filter(ProductVersion.is_purchased == p)
if q is not None: if q is not None:
for sub in q.split(): for sub in q.split():
if sub.strip() != "": if sub.strip() != "":
query_ = query_.filter( 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 item in db.execute(query_).unique().scalars().all():
for sku in item.skus: for sku in [sku.versions[0] for sku in item.product.skus]:
rc_price = get_rc_price(item.id, d, v, db) rc_price = get_rc_price(item.id, date_, v, db)
list_.append( list_.append(
ProductSku( ProductSku(
id_=sku.id, id_=sku.sku_id,
name=f"{item.name} ({sku.units})", name=f"{item.name} ({sku.units})",
fraction_units=item.fraction_units, fraction_units=item.fraction_units,
cost_price=sku.cost_price if rc_price is None else rc_price, 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 q: str | None = None, # Query
a: bool | None = None, # Active a: bool | None = None, # Active
p: bool | None = None, # Is Purchased? p: bool | None = None, # Is Purchased?
date_: date = Depends(effective_date), # Date
current_user: UserToken = Depends(get_user), current_user: UserToken = Depends(get_user),
) -> list[ProductSku]: ) -> list[ProductSku]:
list_ = [] list_ = []
with SessionFuture() as db: with SessionFuture() as db:
query_ = select(Product) query_ = select(ProductVersion).where(
if a is not None: and_(
query_ = query_.filter(Product.is_active == a) 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: if p is not None:
query_ = query_.filter(Product.is_purchased == p) query_ = query_.filter(ProductVersion.is_purchased == p)
if q is not None: if q is not None:
for sub in q.split(): for sub in q.split():
if sub.strip() != "": if sub.strip() != "":
query_ = query_.filter(Product.name.ilike(f"%{sub}%")) query_ = query_.filter(ProductVersion.name.ilike(f"%{sub}%"))
query_ = query_.order_by(Product.name) query_ = query_.order_by(ProductVersion.name)
for item in db.execute(query_).unique().scalars().all(): for item in db.execute(query_).unique().scalars().all():
list_.append( list_.append(
@@ -297,20 +525,57 @@ async def show_term_product(
@router.get("/{id_}", response_model=schemas.Product) @router.get("/{id_}", response_model=schemas.Product)
def show_id( def show_id(
id_: uuid.UUID, id_: uuid.UUID,
date_: date = Depends(effective_date),
user: UserToken = Security(get_user, scopes=["products"]), user: UserToken = Security(get_user, scopes=["products"]),
) -> schemas.Product: ) -> schemas.Product:
with SessionFuture() as db: with SessionFuture() as db:
item: Product = db.execute(select(Product).where(Product.id == id_)).scalar_one() version: ProductVersion = db.execute(
return product_info(item) 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( return schemas.Product(
id_=product.id, id_=version.product_id,
code=product.code, handle=version.handle,
name=product.name, name=version.name,
description=product.description, description=version.description,
fraction_units=product.fraction_units, fraction_units=version.fraction_units,
skus=[ skus=[
schemas.StockKeepingUnit( schemas.StockKeepingUnit(
id_=sku.id, id_=sku.id,
@@ -320,27 +585,27 @@ def product_info(product: Product) -> schemas.Product:
cost_price=sku.cost_price, cost_price=sku.cost_price,
sale_price=sku.sale_price, sale_price=sku.sale_price,
) )
for sku in product.skus for sku in skus
], ],
is_active=product.is_active, is_fixture=version.product.is_fixture,
is_fixture=product.is_fixture, is_purchased=version.is_purchased,
is_purchased=product.is_purchased, is_sold=version.is_sold,
is_sold=product.is_sold, is_active=True,
product_group=schemas.ProductGroupLink(id_=product.product_group.id, name=product.product_group.name), product_group=schemas.ProductGroupLink(id_=version.product_group.id, name=version.product_group.name),
allergen=product.allergen, allergen=version.allergen,
protein=product.protein, protein=version.protein,
carbohydrate=product.carbohydrate, carbohydrate=version.carbohydrate,
total_sugar=product.total_sugar, total_sugar=version.total_sugar,
added_sugar=product.added_sugar, added_sugar=version.added_sugar,
total_fat=product.total_fat, total_fat=version.total_fat,
saturated_fat=product.saturated_fat, saturated_fat=version.saturated_fat,
trans_fat=product.trans_fat, trans_fat=version.trans_fat,
cholestrol=product.cholestrol, cholestrol=version.cholestrol,
sodium=product.sodium, sodium=version.sodium,
msnf=product.msnf, msnf=version.msnf,
other_solids=product.other_solids, other_solids=version.other_solids,
total_solids=product.total_solids, total_solids=version.total_solids,
water=product.water, water=version.water,
) )
@@ -350,10 +615,10 @@ def product_blank() -> schemas.ProductBlank:
description="", description="",
fraction_units="", fraction_units="",
skus=[], skus=[],
is_active=True,
is_purchased=True, is_purchased=True,
is_sold=False, is_sold=False,
is_fixture=False, is_fixture=False,
is_active=True,
allergen="", allergen="",
protein=Decimal(0), protein=Decimal(0),
carbohydrate=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: def get_rc_price(id_: uuid.UUID, date_: date, v: uuid.UUID | None, db: Session) -> Decimal | None:
if d is None or v is None: if v is None:
return None return None
date_ = datetime.strptime(d, "%d-%b-%Y")
contracts = select(RateContract.id).where( contracts = select(RateContract.id).where(
RateContract.vendor_id == v, RateContract.valid_from <= date_, RateContract.valid_till >= date_ RateContract.vendor_id == v, RateContract.valid_from <= date_, RateContract.valid_till >= date_
) )
+113 -17
View File
@@ -4,13 +4,15 @@ from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status 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.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import brewman.schemas.input as schema_in import brewman.schemas.input as schema_in
import brewman.schemas.voucher as output 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 brewman.routers.tag import save_tags, update_tags
from ..core.security import get_current_active_user as get_user 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: def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher:
product_accounts = ( product_ids = select(func.distinct(StockKeepingUnit.product_id)).where(
select(Product.account_id) StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])
.join(Product.skus) )
.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( account_types: list[int] = list(
db.execute( db.execute(
@@ -138,7 +151,23 @@ def save_inventories(voucher: Voucher, vendor_id: uuid.UUID, inventories: list[I
tax=item.tax, tax=item.tax,
discount=item.discount, 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) voucher.inventories.append(inventory)
db.add(inventory) db.add(inventory)
@@ -148,11 +177,22 @@ def save_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) ->
journals: dict[uuid.UUID, Journal] = {} journals: dict[uuid.UUID, Journal] = {}
amount = Decimal(0) amount = Decimal(0)
for i_item in voucher.inventories: 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( account_id, cc_id = db.execute(
select(AccountBase.id, AccountBase.cost_centre_id) select(AccountBase.id, AccountBase.cost_centre_id).where(AccountBase.id == product_account)
.join(AccountBase.products)
.join(Product.skus)
.where(StockKeepingUnit.id == i_item.batch.sku.id)
).one() ).one()
amount += round(i_item.amount, 2) amount += round(i_item.amount, 2)
if account_id in journals: 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: 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() voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one()
product_accounts = ( product_ids = select(func.distinct(StockKeepingUnit.product_id)).where(
select(Product.account_id) StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])
.join(Product.skus)
.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( account_types: list[int] = list(
db.execute( db.execute(
select(distinct(AccountBase.type_id)).where( 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), (idx for (idx, d) in enumerate(inventories) if d.id_ == item.id and d.batch.sku.id_ == item.batch.sku_id),
None, 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: if index is not None:
new_inventory = inventories.pop(index) new_inventory = inventories.pop(index)
sku: StockKeepingUnit = db.execute( sku: StockKeepingUnit = db.execute(
select(StockKeepingUnit).where(StockKeepingUnit.id == item.batch.sku.id) select(StockKeepingUnit).where(StockKeepingUnit.id == item.batch.sku.id)
).scalar_one() ).scalar_one()
rc_price = rate_contract_price(sku.id, vendor_id, voucher.date_, db) 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): if batch_has_older_vouchers(item.batch_id, voucher.date_, voucher.id, db):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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: if rc_price is not None and rc_price != new_inventory.rate:
raise HTTPException( 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.batch.discount = new_inventory.discount
item.tax = new_inventory.tax item.tax = new_inventory.tax
item.batch.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() db.flush()
fix_single_batch_prices(item.batch_id, db) fix_single_batch_prices(item.batch_id, db)
else: else:
@@ -290,7 +374,7 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, inventories: list[I
if has_been_issued > 0: if has_been_issued > 0:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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: else:
db.delete(item.batch) db.delete(item.batch)
@@ -304,9 +388,21 @@ def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) -
journals: dict[uuid.UUID, Journal] = {} journals: dict[uuid.UUID, Journal] = {}
amount = Decimal(0) amount = Decimal(0)
for i_item in voucher.inventories: 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( account_id, cc_id = db.execute(
select(AccountBase.id, AccountBase.cost_centre_id) select(AccountBase.id, AccountBase.cost_centre_id)
.join(AccountBase.products) .join(AccountBase.products)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .join(Product.skus)
.where(StockKeepingUnit.id == i_item.batch.sku.id) .where(StockKeepingUnit.id == i_item.batch.sku.id)
).one() ).one()
+126 -9
View File
@@ -4,13 +4,15 @@ from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status 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.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.input as schema_in
import brewman.schemas.voucher as output 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 brewman.routers.tag import save_tags, update_tags
from ..core.security import get_current_active_user as get_user 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: 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 = ( product_accounts = (
select(Product.account_id) select(ProductVersion.account_id)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .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])) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]))
) )
account_types: list[int] = list( 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: def save_inventories(voucher: Voucher, inventories: list[InventorySchema], db: Session) -> None:
for d_item in inventories: for d_item in inventories:
batch = db.execute(select(Batch).where(Batch.id == d_item.batch.id_)).scalar_one() 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: if d_item.quantity > batch.quantity_remaining:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 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_: if batch.name > voucher.date_:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{batch.sku.product.name} ({batch.sku.units}) " detail=f"{product_name} ({product_unit}) was purchased on {batch.name.strftime('%d-%b-%Y')}",
f"was purchased on {batch.name.strftime('%d-%b-%Y')}",
) )
batch.quantity_remaining -= d_item.quantity 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] = {} journals: dict[uuid.UUID, Journal] = {}
amount = Decimal(0) amount = Decimal(0)
for v_item in voucher.inventories: 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( account_id, cc_id = db.execute(
select(AccountBase.id, AccountBase.cost_centre_id) select(AccountBase.id, AccountBase.cost_centre_id)
.join(AccountBase.products) .join(AccountBase.products)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .join(Product.skus)
.where(StockKeepingUnit.id == v_item.batch.sku.id) .where(StockKeepingUnit.id == v_item.batch.sku.id)
).one() ).one()
@@ -199,11 +258,28 @@ def update_route(
def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: 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() 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 = ( product_accounts = (
select(Product.account_id) select(ProductVersion.account_id)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .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])) .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]))
) )
account_types: list[int] = list( account_types: list[int] = list(
db.execute( db.execute(
select(distinct(AccountBase.type_id)).where( select(distinct(AccountBase.type_id)).where(
@@ -240,13 +316,42 @@ def update_inventory(voucher: Voucher, new_inventories: list[InventorySchema], d
index = next( index = next(
(idx for (idx, d) in enumerate(new_inventories) if d.id_ == item.id and d.batch.id_ == item.batch.id), None (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: if index is not None:
new_inventory = new_inventories.pop(index) new_inventory = new_inventories.pop(index)
if new_inventory.quantity > batch_quantity: if new_inventory.quantity > batch_quantity:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Maximum quantity available for " detail=f"Maximum quantity available for {product_name} ({product_unit}) is {batch_quantity}",
f"{batch.sku.product.name} ({batch.sku.units}) is {batch_quantity}",
) )
if batch.name > voucher.date_: if batch.name > voucher.date_:
raise HTTPException( raise HTTPException(
@@ -270,9 +375,21 @@ def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session) -
journals: dict[uuid.UUID, Journal] = {} journals: dict[uuid.UUID, Journal] = {}
amount = Decimal(0) amount = Decimal(0)
for v_item in voucher.inventories: 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( account_id, cc_id = db.execute(
select(AccountBase.id, AccountBase.cost_centre_id) select(AccountBase.id, AccountBase.cost_centre_id)
.join(AccountBase.products) .join(AccountBase.products)
.join(Product, onclause=product_version_onclause)
.join(Product.skus) .join(Product.skus)
.where(StockKeepingUnit.id == v_item.batch.sku.id) .where(StockKeepingUnit.id == v_item.batch.sku.id)
).one() ).one()
+47 -9
View File
@@ -1,12 +1,17 @@
import uuid import uuid
from datetime import date
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException, Request, Security, status 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.exc import SQLAlchemyError
from sqlalchemy.orm import Session 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.security import get_current_active_user as get_user
from ..core.session import ( from ..core.session import (
get_date, get_date,
@@ -50,7 +55,7 @@ async def save(
set_date(data.date_, request.session) set_date(data.date_, request.session)
set_period(data.valid_from, data.valid_till, request.session) set_period(data.valid_from, data.valid_till, request.session)
db.commit() db.commit()
return rate_contract_info(item) return rate_contract_info(item, db)
except SQLAlchemyError as e: except SQLAlchemyError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -85,7 +90,7 @@ async def update_route(
set_date(data.date_, request.session) set_date(data.date_, request.session)
set_period(data.valid_from, data.valid_till, request.session) set_period(data.valid_from, data.valid_till, request.session)
db.commit() db.commit()
return rate_contract_info(item) return rate_contract_info(item, db)
except SQLAlchemyError as e: except SQLAlchemyError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -134,7 +139,7 @@ async def show_list(
) -> list[RateContractSchema]: ) -> list[RateContractSchema]:
with SessionFuture() as db: with SessionFuture() as db:
return [ return [
rate_contract_info(item) rate_contract_info(item, db)
for item in db.execute(select(RateContract).order_by(RateContract.date_)).scalars().all() for item in db.execute(select(RateContract).order_by(RateContract.date_)).scalars().all()
] ]
@@ -146,10 +151,10 @@ async def show_id(
) -> RateContractSchema: ) -> RateContractSchema:
with SessionFuture() as db: with SessionFuture() as db:
item: RateContract = db.execute(select(RateContract).where(RateContract.id == id_)).scalar_one() 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( return RateContractSchema(
id_=item.id, id_=item.id,
date_=item.date_, date_=item.date_,
@@ -163,9 +168,7 @@ def rate_contract_info(item: RateContract) -> RateContractSchema:
items=[ items=[
RateContractItemSchema( RateContractItemSchema(
id_=i.id, id_=i.id,
sku=ProductLink( sku=ProductLink(id_=i.sku_id, name=get_product_name(i.sku, item.date_, db)),
id_=i.sku_id, name=f"{i.sku.product.name} ({i.sku.units})" if i.sku.units else i.sku.product.name
),
price=i.price, price=i.price,
) )
for i in item.items 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: def rate_contract_blank(session: dict[str, Any]) -> RateContractBlank:
return RateContractBlank( return RateContractBlank(
date_=get_date(session), # type: ignore[arg-type] date_=get_date(session), # type: ignore[arg-type]
+2 -2
View File
@@ -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: 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) sum_func = func.sum(Journal.signed_amount)
query = db.execute( query = db.execute(
select(AccountBase, sum_func) 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: for account, amount in query:
amount = round(Decimal(amount), 2) 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 running_total += amount
journal = Journal( journal = Journal(
amount=abs(amount), amount=abs(amount),
+115 -24
View File
@@ -12,13 +12,16 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Security, status
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, NamedStyle, PatternFill, Side 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.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 as schemas
import brewman.schemas.recipe_item as rischemas 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 ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture from ..db.session import SessionFuture
from ..models.price import Price from ..models.price import Price
@@ -207,11 +210,44 @@ async def show_list(
user: UserToken = Depends(get_user), user: UserToken = Depends(get_user),
) -> list[schemas.Recipe]: ) -> list[schemas.Recipe]:
with SessionFuture() as db: 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] = ( list_: Sequence[Recipe] = (
db.execute( db.execute(
select(Recipe) select(Recipe)
.options(joinedload(Recipe.sku, innerjoin=True).joinedload(StockKeepingUnit.product, innerjoin=True)) .join(Recipe.sku)
.order_by(Recipe.sku_id) .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() .scalars()
.all() .all()
@@ -219,7 +255,7 @@ async def show_list(
return [ return [
schemas.Recipe( schemas.Recipe(
id_=item.id, 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_, date_=item.date_,
source=item.source, source=item.source,
instructions=item.instructions, instructions=item.instructions,
@@ -227,8 +263,8 @@ async def show_list(
plating=item.plating, plating=item.plating,
recipe_yield=item.recipe_yield, recipe_yield=item.recipe_yield,
notes=item.notes, notes=item.notes,
product_group_id=item.sku.product.product_group_id, product_group_id=item.sku.product.versions[0].product_group_id,
units=item.sku.units, units=item.sku.versions[0].units,
items=[], items=[],
) )
for item in list_ for item in list_
@@ -267,7 +303,7 @@ def show_pdf(
@router.get("/xlsx", response_class=StreamingResponse) @router.get("/xlsx", response_class=StreamingResponse)
def get_report( def get_report(
p: uuid.UUID | None = None, p: uuid.UUID,
t: uuid.UUID | None = None, t: uuid.UUID | None = None,
) -> StreamingResponse: ) -> StreamingResponse:
with SessionFuture() as db: with SessionFuture() as db:
@@ -281,24 +317,79 @@ def get_report(
.scalars() .scalars()
.all() .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] = [] list_: Sequence[Recipe] = []
with SessionFuture() as db: 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 = ( q = (
select(Recipe) select(Recipe)
.join(Recipe.items)
.join(RecipeItem.product)
.join(ItemProductVersion, item_product_version_onclause)
.join(Recipe.sku) .join(Recipe.sku)
.join(CurrentSkuVersion, sku_version_onclause)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(Product.product_group) .join(RecipeProductVersion, recipe_product_version_onclause)
.join(RecipeProductVersion.product_group)
.options( .options(
joinedload(Recipe.items, innerjoin=True).joinedload(RecipeItem.product, innerjoin=True), # Eagerly load ingredients and their product & product_version
contains_eager(Recipe.sku, StockKeepingUnit.product, Product.product_group), 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: 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() 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) e.seek(0)
headers = {"Content-Disposition": "attachment; filename = recipe.xlsx"} 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=2, value=p[1])
wb.active.cell(row=i, column=3, value=p[2]) 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: for pg in pgs:
wb.create_sheet(pg) wb.create_sheet(pg)
rows = defaultdict(lambda: 1) rows = defaultdict(lambda: 1)
register_styles(wb) register_styles(wb)
for recipe in recipes: for recipe in recipes:
ws = wb[recipe.sku.product.product_group.name] ws = wb[recipe.sku.product.versions[0].product_group.name]
row = rows[recipe.sku.product.product_group.name] row = rows[recipe.sku.product.versions[0].product_group.name]
print(row) print(row)
ings = len(recipe.items) ings = len(recipe.items)
ing_from = row + 2 ing_from = row + 2
ing_till = ing_from + ings - 1 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=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=3, value=recipe.recipe_yield).style = "recipe_name"
ws.cell(row=row, column=4).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" 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" ws.cell(row=row, column=5, value="Amount").style = "header"
for item in recipe.items: for item in recipe.items:
row += 1 row += 1
ws.cell(row=row, column=1, value=item.product.name).style = "ing" ws.cell(row=row, column=1, value=item.product.versions[0].name).style = "ing"
ws.cell(row=row, column=2, value=item.product.fraction_units).style = "unit" 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=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=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" 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() virtual_workbook = BytesIO()
wb.save(virtual_workbook) wb.save(virtual_workbook)
return virtual_workbook return virtual_workbook
@@ -466,7 +557,7 @@ def recipe_info(recipe: Recipe) -> schemas.Recipe:
id_=recipe.id, id_=recipe.id,
sku=schemas.ProductLink( sku=schemas.ProductLink(
id_=recipe.sku_id, 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_, date_=recipe.date_,
source=recipe.source, source=recipe.source,
@@ -480,7 +571,7 @@ def recipe_info(recipe: Recipe) -> schemas.Recipe:
id_=item.id, id_=item.id,
product=schemas.ProductLink( product=schemas.ProductLink(
id_=item.product.id, 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), quantity=round(item.quantity, 2),
description=item.description, description=item.description,
@@ -4,12 +4,16 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, HTTPException, Request, Security, status from fastapi import APIRouter, HTTPException, Request, Security, status
from sqlalchemy import and_
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, contains_eager from sqlalchemy.orm import Session, contains_eager
from sqlalchemy.sql.expression import delete, distinct, func, or_, select, update from sqlalchemy.sql.expression import delete, distinct, func, or_, select, update
import brewman.schemas.closing_stock as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_finish_date, get_start_date, set_date, set_period from ...core.session import get_finish_date, get_start_date, set_date, set_period
from ...db.session import SessionFuture 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]: 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") 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") quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity")
query = db.execute( product_version_onclause = and_(
select(StockKeepingUnit, quantity_sum, amount_sum) ProductVersion.product_id == Product.id,
.join(StockKeepingUnit.product) or_(
.join(Product.product_group) ProductVersion.valid_from == None, # noqa: E711
.join(StockKeepingUnit.batches) ProductVersion.valid_from <= Voucher.date_,
.join(Batch.inventories) ),
.join(Inventory.voucher) or_(
.join(Voucher.journals) ProductVersion.valid_till == None, # noqa: E711
.options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) ProductVersion.valid_till >= Voucher.date_,
.where( ),
Voucher.date_ <= date_, )
Journal.cost_centre_id == cost_centre_id,
or_(Voucher.voucher_type != VoucherType.CLOSING_STOCK, Voucher.date_ != 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 .unique()
.order_by(ProductGroup.name, Product.name, StockKeepingUnit.units) .all()
).all() )
physical_list = ( physical_list = (
db.execute( db.execute(
@@ -179,16 +218,18 @@ def build_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> list[sc
).all() ).all()
body = [] body = []
for sku, quantity, amount in query: for sku_version, quantity, amount in query:
if quantity != 0 and amount != 0: if quantity != 0 and amount != 0:
id_ = next((p.id for p in physical_list if p.sku_id == 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.id), quantity) 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.id), None) cc = next((schemas.CostCentreLink(id_=c) for (c, s) in ccs if s == sku_version.sku_id), None)
body.append( body.append(
schemas.ClosingStockItem( schemas.ClosingStockItem(
id_=id_, id_=id_,
product=schemas.ProductLink(id_=sku.id, name=f"{sku.product.name} ({sku.units})"), product=schemas.ProductLink(
group=sku.product.product_group.name, 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, quantity=quantity,
amount=amount, amount=amount,
physical=physical, 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: def save(date_: date, items: list[schemas.ClosingStockItem], user: UserToken, db: Session) -> Voucher:
product_accounts: list[uuid.UUID] = list( # type: ignore product_version_onclause = and_(
select(Product.account_id).join(Product.skus).where(StockKeepingUnit.id.in_([i.product.id_ for i in items])) 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( account_types: list[int] = list(
db.execute(select(distinct(AccountBase.type_id)).where(AccountBase.id.in_(product_accounts))).scalars().all() db.execute(select(distinct(AccountBase.type_id)).where(AccountBase.id.in_(product_accounts))).scalars().all()
@@ -4,13 +4,16 @@ from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, HTTPException, Request, Security, status 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.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import select from sqlalchemy.sql.expression import select
import brewman.schemas.mozimo_daily_register as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_date, set_date from ...core.session import get_date, set_date
from ...db.session import SessionFuture from ...db.session import SessionFuture
@@ -42,30 +45,55 @@ def show_data(
body = build_report(d, db) body = build_report(d, db)
set_date(date_, request.session) set_date(date_, request.session)
return schemas.MozimoDailyRegister( return schemas.MozimoDailyRegister(
date=d, date_=d,
body=body, body=body,
) )
def build_report(date_: date, db: Session) -> list[schemas.MozimoDailyRegisterItem]: def build_report(date_: date, db: Session) -> list[schemas.MozimoDailyRegisterItem]:
body = [] 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 = ( products = (
db.execute( db.execute(
select(StockKeepingUnit) select(ProductVersion.name, SkuVersion.units, SkuVersion.sku_id)
.join(StockKeepingUnit.product) .select_from(Product)
.where(Product.product_group_id == uuid.UUID("dad46805-f577-4e5b-8073-9b788e0173fc")) # Menu items .join(ProductVersion, onclause=product_version_onclause)
.order_by(Product.name, StockKeepingUnit.units) .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() .scalars()
.all() .all()
) )
for sku in products: for name, units, sku_id in products:
ob = opening_balance(sku.id, date_, db) ob = opening_balance(sku_id, date_, db)
item = ( item = (
db.execute( db.execute(
select(MozimoStockRegister).where( select(MozimoStockRegister).where(
MozimoStockRegister.sku_id == sku.id, MozimoStockRegister.sku_id == sku_id,
MozimoStockRegister.date_ == date_, MozimoStockRegister.date_ == date_,
) )
) )
@@ -75,7 +103,7 @@ def build_report(date_: date, db: Session) -> list[schemas.MozimoDailyRegisterIt
body.append( body.append(
schemas.MozimoDailyRegisterItem( schemas.MozimoDailyRegisterItem(
id_=None if item is None else item.id_, 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, opening=ob,
received=Decimal(0) if item is None else item.received, received=Decimal(0) if item is None else item.received,
sale=Decimal(0) if item is None else item.sale, 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() .scalars()
.one_or_none() .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( query_ = select(func.sum(MozimoStockRegister.received - MozimoStockRegister.sale - MozimoStockRegister.nc)).where(
MozimoStockRegister.sku_id == product_id, MozimoStockRegister.sku_id == product_id,
MozimoStockRegister.date_ < start_date, MozimoStockRegister.date_ < start_date,
) )
if opening_physical is not None: if opening_physical is not None:
query_ = query_.where(MozimoStockRegister.date_ > opening_physical.date_) 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 return physical + calculated
@@ -53,7 +53,7 @@ def show_data(
return schemas.MozimoProductRegister( return schemas.MozimoProductRegister(
start_date=start_date, start_date=start_date,
finish_date=finish_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, body=body,
) )
@@ -91,7 +91,7 @@ def build_report(
) )
if item is not None: if item is not None:
if item.ageing is not None or item.display 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: else:
closing = ob + item.received - item.sale - item.nc closing = ob + item.received - item.sale - item.nc
ob = closing # Setting the cb as ob for next iteration 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) ob = opening_balance(data.product.id_, data.start_date, db)
for item in data.body: for item in data.body:
if item.ageing is not None or item.display 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: else:
closing = ob + item.received - item.sale - item.nc closing = ob + item.received - item.sale - item.nc
ob = closing # Setting the cb as ob for next iteration ob = closing # Setting the cb as ob for next iteration
@@ -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
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Security from fastapi import APIRouter, Security
from sqlalchemy import select from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, contains_eager, joinedload from sqlalchemy.orm import Session, contains_eager, joinedload
import brewman.schemas.non_contract_purchase as schemas 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.inventory import Inventory
from brewman.models.journal import Journal from brewman.models.journal import Journal
from brewman.models.product import Product from brewman.models.product import Product
from brewman.models.product_version import ProductVersion
from brewman.models.rate_contract import RateContract 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.stock_keeping_unit import StockKeepingUnit
from brewman.models.voucher import Voucher from brewman.models.voucher import Voucher
from brewman.models.voucher_type import VoucherType from brewman.models.voucher_type import VoucherType
@@ -46,13 +48,41 @@ def report(db: Session) -> list[schemas.NonContractPurchase]:
.all() .all()
) )
list_: list[schemas.NonContractPurchase] = [] 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 rc in rcs:
for item in rc.items: for item in rc.items:
invs = db.execute( invs = db.execute(
select(Voucher.id, Voucher.date_, Inventory.id, Inventory.rate) select(Voucher.id, Voucher.date_, Inventory.rate, ProductVersion.name, SkuVersion.units)
.join(Inventory.batch) .select_from(Voucher)
.join(Inventory.voucher)
.join(Voucher.journals) .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( .where(
Batch.sku_id == item.sku_id, Batch.sku_id == item.sku_id,
Voucher.date_ >= rc.valid_from, Voucher.date_ >= rc.valid_from,
@@ -62,24 +92,19 @@ def report(db: Session) -> list[schemas.NonContractPurchase]:
Inventory.rate != item.price, Inventory.rate != item.price,
) )
).all() ).all()
for inv in invs: for voucher_id, date_, rate, name, units in invs:
p, u = db.execute(
select(Product.name, StockKeepingUnit.units)
.join(Product.skus)
.where(StockKeepingUnit.id == item.sku_id)
).one()
list_.append( list_.append(
schemas.NonContractPurchase( schemas.NonContractPurchase(
date_=inv.date_, date_=date_,
vendor=rc.vendor.name, vendor=rc.vendor.name,
product=f"{p} ({u})", product=f"{name} ({units})",
url=[ url=[
"/", "/",
"purchase", "purchase",
str(inv.id), str(voucher_id),
], ],
contract_price=item.price, contract_price=item.price,
purchase_price=inv.rate, purchase_price=rate,
) )
) )
return list_ return list_
@@ -4,11 +4,14 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Request, Security 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 from sqlalchemy.sql.expression import func, select
import brewman.schemas.product_ledger as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_finish_date, get_start_date, set_period from ...core.session import get_finish_date, get_start_date, set_period
from ...db.session import SessionFuture from ...db.session import SessionFuture
@@ -61,7 +64,7 @@ def show_data(
return schemas.ProductLedger( return schemas.ProductLedger(
start_date=start_date, start_date=start_date,
finish_date=finish_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, body=body,
) )
@@ -72,16 +75,31 @@ def build_report(
running_total_q, running_total_a, opening = opening_balance(product_id, start_date, db) running_total_q, running_total_a, opening = opening_balance(product_id, start_date, db)
body = opening body = opening
query = db.execute( sku_version_onclause = and_(
select(Voucher, Inventory, Journal, StockKeepingUnit) 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(Voucher.journals)
.join(Journal.account)
.join(Journal.cost_centre)
.join(Voucher.inventories) .join(Voucher.inventories)
.join(Inventory.batch) .join(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(SkuVersion, onclause=sku_version_onclause)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.options( .options(
joinedload(Journal.account, innerjoin=True), contains_eager(Voucher.journals).contains_eager(Journal.account),
joinedload(Journal.cost_centre, innerjoin=True), contains_eager(Voucher.journals).contains_eager(Journal.cost_centre),
) )
.where( .where(
StockKeepingUnit.product_id == product_id, StockKeepingUnit.product_id == product_id,
@@ -141,16 +159,30 @@ def build_report(
def opening_balance( def opening_balance(
product_id: uuid.UUID, start_date: date, db: Session product_id: uuid.UUID, start_date: date, db: Session
) -> tuple[Decimal, Decimal, list[schemas.ProductLedgerItem]]: ) -> 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( row = db.execute(
select( select(
StockKeepingUnit.units, SkuVersion.units,
func.sum(Inventory.quantity * Journal.debit).label("quantity"), func.sum(Inventory.quantity * Journal.debit).label("quantity"),
func.sum(Inventory.amount * Journal.debit).label("amount"), func.sum(Inventory.amount * Journal.debit).label("amount"),
) )
.select_from(Voucher)
.join(Voucher.journals)
.join(Voucher.inventories)
.join(Inventory.batch) .join(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(Inventory.voucher) .join(SkuVersion, onclause=sku_version_onclause)
.join(Voucher.journals)
.where( .where(
Voucher.id == Inventory.voucher_id, Voucher.id == Inventory.voucher_id,
Voucher.id == Journal.voucher_id, Voucher.id == Journal.voucher_id,
@@ -158,7 +190,7 @@ def opening_balance(
Journal.cost_centre_id == CostCentre.cost_centre_purchase(), Journal.cost_centre_id == CostCentre.cost_centre_purchase(),
Voucher.date_ < start_date, Voucher.date_ < start_date,
) )
.group_by(StockKeepingUnit.units) .group_by(SkuVersion.units)
).all() ).all()
quantity: Decimal = sum(r.quantity for r in row) quantity: Decimal = sum(r.quantity for r in row)
@@ -1,11 +1,14 @@
from datetime import date, datetime from datetime import date, datetime
from fastapi import APIRouter, Request, Security from fastapi import APIRouter, Request, Security
from sqlalchemy import select from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, contains_eager from sqlalchemy.orm import Session, contains_eager
import brewman.schemas.purchase_entries as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_finish_date, get_start_date, set_period from ...core.session import get_finish_date, get_start_date, set_period
from ...db.session import SessionFuture 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]: def build_report(start_date: date, finish_date: date, db: Session) -> list[schemas.PurchaseEntriesItem]:
body = [] 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( query: list[Voucher] = list(
db.execute( db.execute(
select(Voucher) select(Voucher)
@@ -59,12 +73,14 @@ def build_report(start_date: date, finish_date: date, db: Session) -> list[schem
.join(Inventory.batch) .join(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.options( .options(
contains_eager(Voucher.journals).contains_eager(Journal.account), contains_eager(Voucher.journals).contains_eager(Journal.account),
contains_eager(Voucher.inventories) contains_eager(Voucher.inventories)
.contains_eager(Inventory.batch) .contains_eager(Inventory.batch)
.contains_eager(Batch.sku) .contains_eager(Batch.sku)
.contains_eager(StockKeepingUnit.product), .contains_eager(StockKeepingUnit.product)
.contains_eager(Product.versions),
) )
.where( .where(
Voucher.date_ >= start_date, 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(), voucher.voucher_type.name.replace("_", "-").lower(),
str(voucher.id), str(voucher.id),
], ],
product=item.batch.sku.product.name, product=item.batch.sku.product.versions[0].name,
quantity=item.quantity, quantity=item.quantity,
rate=item.rate, rate=item.rate,
tax=item.tax, tax=item.tax,
+34 -5
View File
@@ -2,12 +2,15 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Request, Security from fastapi import APIRouter, Request, Security
from sqlalchemy import not_ from sqlalchemy import and_, not_, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import desc, func, select from sqlalchemy.sql.expression import desc, func, select
import brewman.schemas.purchases as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_finish_date, get_start_date, set_period from ...core.session import get_finish_date, get_start_date, set_period
from ...db.session import SessionFuture from ...db.session import SessionFuture
@@ -62,25 +65,51 @@ def build_report(
body = [] body = []
quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") 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") amount_sum = func.sum(Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax)).label("amount")
query = db.execute( product_version_onclause = and_(
select(Product, quantity_sum, amount_sum) 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.journals)
.join(Voucher.inventories) .join(Voucher.inventories)
.join(Inventory.batch) .join(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(SkuVersion, onclause=sku_version_onclause)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(ProductVersion, onclause=product_version_onclause)
.join(ProductVersion.product_group)
.where( .where(
Voucher.date_ >= datetime.strptime(start_date, "%d-%b-%Y"), Voucher.date_ >= datetime.strptime(start_date, "%d-%b-%Y"),
Voucher.date_ <= datetime.strptime(finish_date, "%d-%b-%Y"), Voucher.date_ <= datetime.strptime(finish_date, "%d-%b-%Y"),
not_(Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK])), not_(Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK])),
Journal.cost_centre_id == CostCentre.cost_centre_purchase(), Journal.cost_centre_id == CostCentre.cost_centre_purchase(),
) )
.group_by(Product) # type: ignore .group_by(ProductVersion) # type: ignore
.order_by(desc(amount_sum)) .order_by(desc(amount_sum))
).all() ).all()
total_amount = Decimal(0) total_amount = Decimal(0)
for product, quantity, amount in query: 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 total_amount += amount
row = schemas.PurchasesItem( row = schemas.PurchasesItem(
name=product.name, name=product.name,
@@ -4,11 +4,15 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Request, Security from fastapi import APIRouter, Request, Security
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import case, func, select from sqlalchemy.sql.expression import case, func, select
import brewman.schemas.raw_material_cost as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_finish_date, get_start_date, set_period from ...core.session import get_finish_date, get_start_date, set_period
from ...db.session import SessionFuture 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_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") 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) select(CostCentre, sum_issue, sum_sale)
.join(CostCentre.journals) .join(CostCentre.journals)
.join(Journal.voucher) .join(Journal.voucher)
@@ -109,7 +113,7 @@ def build_report(
for cost_centre, issue, sale in query: for cost_centre, issue, sale in query:
issues += issue issues += issue
sales += sale sales += sale
rmc = 0 if sale == 0 else issue / sale rmc = Decimal(0) if sale == 0 else issue / sale
body.append( body.append(
schemas.RawMaterialCostItem( schemas.RawMaterialCostItem(
name=cost_centre.name, 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) 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_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net")
sum_gross = func.sum(Inventory.amount * Journal.debit).label("gross") sum_gross = func.sum(Inventory.amount * Journal.debit).label("gross")
query = db.execute( product_version_onclause = and_(
select(Product, ProductGroup, sum_quantity, sum_net, sum_gross) 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(Inventory.batch)
.join(Batch.sku) .join(Batch.sku)
.join(SkuVersion, onclause=sku_version_onclause)
.join(StockKeepingUnit.product) .join(StockKeepingUnit.product)
.join(Product.product_group) .join(ProductVersion, onclause=product_version_onclause)
.join(Inventory.voucher) .join(ProductVersion.product_group)
.join(Voucher.journals)
.where( .where(
Voucher.date_ >= start_date, Voucher.date_ >= start_date,
Voucher.date_ <= finish_date, Voucher.date_ <= finish_date,
Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]), Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]),
Journal.cost_centre_id == cost_centre_id, 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()) .order_by(ProductGroup.name, sum_net.desc())
).all() ).all()
+100 -50
View File
@@ -2,12 +2,15 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Request, Security 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.orm import Session, contains_eager
from sqlalchemy.sql.expression import func, select from sqlalchemy.sql.expression import func, select
import brewman.schemas.stock_movement as schemas 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.security import get_current_active_user as get_user
from ...core.session import get_finish_date, get_start_date, set_period from ...core.session import get_finish_date, get_start_date, set_period
from ...db.session import SessionFuture 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]: def build_stock_movement(start_date: date, finish_date: date, db: Session) -> list[schemas.StockMovementItem]:
dict_ = {} dict_ = {}
quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity")
openings = db.execute( product_version_onclause = and_(
select(StockKeepingUnit, quantity_sum) ProductVersion.product_id == Product.id,
.join(StockKeepingUnit.product) or_(
.join(Product.product_group) ProductVersion.valid_from == None, # noqa: E711
.join(StockKeepingUnit.batches) ProductVersion.valid_from <= Voucher.date_,
.join(Batch.inventories) ),
.join(Inventory.voucher) 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) .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()) .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() ).all()
for sku, quantity in openings: for sku_version, quantity in openings:
dict_[sku.id] = schemas.StockMovementItem( dict_[sku_version.sku_id] = schemas.StockMovementItem(
id_=sku.id, id_=sku_version.id,
name=f"{sku.product.name} ({sku.units})", name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})",
group=sku.product.product_group.name, group=sku_version.sku.product.versions[0].product_group.name,
opening=Decimal(round(quantity, 2)), opening=Decimal(round(quantity, 2)),
purchase=Decimal(0), purchase=Decimal(0),
issue=Decimal(0), issue=Decimal(0),
closing=Decimal(0), closing=Decimal(0),
url=["/", "product-ledger", str(sku.product.id)], url=["/", "product-ledger", str(sku_version.sku.product_id)],
) )
purchases = db.execute( purchases: list[tuple[SkuVersion, Decimal]] = db.execute(
select(StockKeepingUnit, quantity_sum) select(SkuVersion, quantity_sum)
.join(StockKeepingUnit.product) .select_from(Voucher)
.join(Product.product_group)
.join(StockKeepingUnit.batches)
.join(Batch.inventories)
.join(Inventory.voucher)
.join(Voucher.journals) .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( .where(
Voucher.date_ >= start_date, Voucher.date_ >= start_date,
Voucher.date_ <= finish_date, Voucher.date_ <= finish_date,
not_(Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK])), not_(Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK])),
Journal.cost_centre_id == CostCentre.cost_centre_purchase(), 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() ).all()
for sku, quantity in purchases: for sku_version, quantity in purchases:
if sku.id in dict_: if sku_version.sku_id in dict_:
dict_[sku.id].purchase = Decimal(round(quantity, 2)) dict_[sku_version.sku_id].purchase = Decimal(round(quantity, 2))
else: else:
dict_[sku.id] = schemas.StockMovementItem( dict_[sku_version.sku_id] = schemas.StockMovementItem(
id_=sku.id, id_=sku_version.id,
name=f"{sku.product.name} ({sku.units})", name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})",
group=sku.product.product_group.name, group=sku_version.sku.product.versions[0].product_group.name,
opening=Decimal(0), opening=Decimal(0),
purchase=Decimal(round(quantity, 2)), purchase=Decimal(round(quantity, 2)),
issue=Decimal(0), issue=Decimal(0),
closing=Decimal(0), closing=Decimal(0),
url=["/", "product-ledger", str(sku.product.id)], url=["/", "product-ledger", str(sku_version.sku.product_id)],
) )
issues = db.execute( issues: list[tuple[SkuVersion, Decimal]] = db.execute(
select(StockKeepingUnit, quantity_sum) select(SkuVersion, quantity_sum)
.join(StockKeepingUnit.product) .select_from(Voucher)
.join(Product.product_group)
.join(StockKeepingUnit.batches)
.join(Batch.inventories)
.join(Inventory.voucher)
.join(Voucher.journals) .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( .where(
Voucher.date_ >= start_date, Voucher.date_ >= start_date,
Voucher.date_ <= finish_date, Voucher.date_ <= finish_date,
Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]), Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]),
Journal.cost_centre_id == CostCentre.cost_centre_purchase(), 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() ).all()
for sku, quantity in issues: for sku_version, quantity in issues:
if sku.id in dict_: if sku_version.sku_id in dict_:
dict_[sku.id].issue = Decimal(round(quantity * -1, 2)) dict_[sku_version.sku_id].issue = Decimal(round(quantity * -1, 2))
else: else:
dict_[sku.id] = schemas.StockMovementItem( dict_[sku_version.sku_id] = schemas.StockMovementItem(
id_=sku.id, id_=sku_version.id,
name=f"{sku.product.name} ({sku.units})", name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})",
group=sku.product.product_group.name, group=sku_version.sku.product.versions[0].product_group.name,
opening=Decimal(0), opening=Decimal(0),
purchase=Decimal(0), purchase=Decimal(0),
issue=Decimal(round(quantity * -1, 2)), issue=Decimal(round(quantity * -1, 2)),
closing=Decimal(0), 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()] list_ = [value for key, value in dict_.items()]
+68 -6
View File
@@ -11,6 +11,9 @@ from sqlalchemy.orm import Session
import brewman.schemas.voucher as output 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.security import get_current_active_user as get_user
from ..core.session import get_first_day from ..core.session import get_first_day
from ..db.session import SessionFuture from ..db.session import SessionFuture
@@ -137,10 +140,26 @@ def delete_voucher(
else: else:
for i_item in voucher.inventories: for i_item in voucher.inventories:
if i_item.batch.quantity_remaining < i_item.quantity: 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( raise HTTPException(
status_code=status.HTTP_423_LOCKED, status_code=status.HTTP_423_LOCKED,
detail=f"Only {i_item.batch.quantity_remaining}" 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", f"So it cannot be deleted",
) )
i_item.batch.quantity_remaining -= i_item.quantity i_item.batch.quantity_remaining -= i_item.quantity
@@ -152,9 +171,24 @@ def delete_voucher(
) )
).scalar_one() ).scalar_one()
if uses > 0: 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( raise HTTPException(
status_code=status.HTTP_423_LOCKED, 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) batches_to_delete.append(i_item.batch)
elif voucher.voucher_type == VoucherType.PURCHASE_RETURN: 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: if len(json_voucher.incentives) > 0:
json_voucher.incentive = next(x.amount for x in voucher.journals if x.account_id == Account.incentive_id()) json_voucher.incentive = next(x.amount for x in voucher.journals if x.account_id == Account.incentive_id())
for inventory in voucher.inventories: 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 = ( 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"{inventory.batch.quantity_remaining:.2f}@{inventory.batch.rate:.2f} "
f"from {inventory.batch.name.strftime('%d-%b-%Y')}" 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, rate=inventory.batch.rate,
sku=output.ProductLink( sku=output.ProductLink(
id_=inventory.batch.sku.id, id_=inventory.batch.sku.id,
name=f"{inventory.batch.sku.product.name} ({inventory.batch.sku.units})" name=f"{product_name} ({product_unit})" if product_unit else product_name,
if inventory.batch.sku.units
else inventory.batch.sku.product.name,
), ),
), ),
) )
@@ -41,7 +41,7 @@ class MozimoDailyRegisterItem(BaseModel):
class MozimoDailyRegister(BaseModel): class MozimoDailyRegister(BaseModel):
date_: date date_: date | str
body: list[MozimoDailyRegisterItem] body: list[MozimoDailyRegisterItem]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
@@ -52,8 +52,8 @@ class MozimoProductRegisterItem(BaseModel):
class MozimoProductRegister(BaseModel): class MozimoProductRegister(BaseModel):
start_date: date | None = None start_date: date | str
finish_date: date | None = None finish_date: date | str
product: ProductLink | None = None product: ProductLink | None = None
body: list[MozimoProductRegisterItem] body: list[MozimoProductRegisterItem]
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
+1 -1
View File
@@ -46,7 +46,7 @@ class ProductIn(BaseModel):
class Product(ProductIn): class Product(ProductIn):
id_: uuid.UUID id_: uuid.UUID
code: int handle: str
is_fixture: bool is_fixture: bool
@@ -1,5 +1,7 @@
import uuid import uuid
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from . import Daf, to_camel from . import Daf, to_camel
@@ -8,8 +10,8 @@ from . import Daf, to_camel
class StockKeepingUnit(BaseModel): class StockKeepingUnit(BaseModel):
id_: uuid.UUID | None = None id_: uuid.UUID | None = None
units: str = Field(..., min_length=1) units: str = Field(..., min_length=1)
fraction: Daf = Field(ge=1, default=1) fraction: Daf = Field(ge=Decimal(1), default=Decimal(1))
product_yield: Daf = Field(gt=0, le=1, default=1) product_yield: Daf = Field(gt=Decimal(0), le=Decimal(1), default=Decimal(1))
cost_price: Daf = Field(ge=0, default=0) cost_price: Daf = Field(ge=Decimal(0), default=Decimal(0))
sale_price: Daf = Field(ge=0, default=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) model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
+23
View File
@@ -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)
+2 -15
View File
@@ -12,25 +12,12 @@ else
./version_bump.sh ./version_bump.sh
fi fi
# Download the package.json for caching make build-production
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 .
if [ 1 -eq "$#" ] if [ 1 -eq "$#" ]
then then
docker tag brewman:latest "$1" docker tag brewman:latest "$1"
else else
echo "No version bump" echo "No version bump"
fi fi
cd "$parent_path/docker" || exit cd "$parent_path/ansible" || exit
docker save brewman:latest | bzip2 | pv | ssh gondor 'bunzip2 | sudo docker load'
ansible-playbook --inventory hosts playbook.yml ansible-playbook --inventory hosts playbook.yml
@@ -1,11 +1,7 @@
import { CdkScrollable } from '@angular/cdk/scrolling'; import { CdkScrollable } from '@angular/cdk/scrolling';
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { Component, Inject, OnInit } from '@angular/core'; import { Component, Inject, OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { MatOption } from '@angular/material/core';
import { MatDatepicker } from '@angular/material/datepicker';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogRef, MatDialogRef,
@@ -14,12 +10,10 @@ import {
MatDialogActions, MatDialogActions,
MatDialogClose, MatDialogClose,
} from '@angular/material/dialog'; } 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 { MatInput } from '@angular/material/input';
import { MatSelect } from '@angular/material/select';
import { Tag } from '../tag/tag'; import { Tag } from '../tag/tag';
import { AccountingPipe } from '../shared/accounting.pipe';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { LedgerItem } from '../ledger/ledger-item'; import { LedgerItem } from '../ledger/ledger-item';
import { TagList } from './tag-list'; import { TagList } from './tag-list';
@@ -32,24 +26,14 @@ import { MatSnackBar } from '@angular/material/snack-bar';
styleUrls: ['./tag-dialog.component.css'], styleUrls: ['./tag-dialog.component.css'],
standalone: true, standalone: true,
imports: [ imports: [
AccountingPipe,
AsyncPipe,
CurrencyPipe,
CdkScrollable, CdkScrollable,
MatCheckbox, MatCheckbox,
MatDialogTitle, MatDialogTitle,
MatDialogContent, MatDialogContent,
ReactiveFormsModule, ReactiveFormsModule,
MatFormField, MatFormField,
MatSelect,
MatDatepicker,
MatOption,
MatLabel, MatLabel,
MatInput, MatInput,
MatAutocompleteTrigger,
MatHint,
MatAutocomplete,
MatPrefix,
MatDialogActions, MatDialogActions,
MatButton, MatButton,
MatDialogClose, MatDialogClose,