Fix: Recipe now checks for recursion (hopefully)

Feature: Recipe prices are now calculated based on periods and saved
Feature: The recipe export excel now has prices
This commit is contained in:
Amritanshu Agrawal 2023-08-12 08:29:21 +05:30
parent 72843feaac
commit 1705d58dbc
10 changed files with 321 additions and 73 deletions

View File

@ -5,41 +5,46 @@ Revises: a1372ed99c45
Create Date: 2023-04-14 07:50:22.110724
"""
from alembic import op
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '12262aadbc08'
down_revision = 'a1372ed99c45'
revision = "12262aadbc08"
down_revision = "a1372ed99c45"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('recipe_templates',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.Unicode(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('text', sa.Unicode(), nullable=False),
sa.Column('selected', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_recipe_templates')),
sa.UniqueConstraint('name', name=op.f('uq_recipe_templates_name'))
op.create_table(
"recipe_templates",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sa.Unicode(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("text", sa.Unicode(), nullable=False),
sa.Column("selected", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_recipe_templates")),
sa.UniqueConstraint("name", name=op.f("uq_recipe_templates_name")),
)
op.create_index('only_one_selected_template', 'recipe_templates', ['selected'], unique=True, postgresql_where=sa.text('selected = true'))
op.alter_column('recipes', 'notes',
existing_type=sa.VARCHAR(length=255),
type_=sa.Text(),
existing_nullable=False)
op.create_index(
"only_one_selected_template",
"recipe_templates",
["selected"],
unique=True,
postgresql_where=sa.text("selected = true"),
)
op.alter_column("recipes", "notes", existing_type=sa.VARCHAR(length=255), type_=sa.Text(), existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('recipes', 'notes',
existing_type=sa.Text(),
type_=sa.VARCHAR(length=255),
existing_nullable=False)
op.drop_index('only_one_selected_template', table_name='recipe_templates', postgresql_where=sa.text('selected = true'))
op.drop_table('recipe_templates')
op.alter_column("recipes", "notes", existing_type=sa.Text(), type_=sa.VARCHAR(length=255), existing_nullable=False)
op.drop_index(
"only_one_selected_template", table_name="recipe_templates", postgresql_where=sa.text("selected = true")
)
op.drop_table("recipe_templates")
# ### end Alembic commands ###

View File

@ -1,4 +1,4 @@
"""FP
"""Fingerprint Index
Revision ID: 48af31eb6f3f
Revises: 12262aadbc08
@ -6,19 +6,18 @@ Create Date: 2023-08-07 13:01:05.401492
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '48af31eb6f3f'
down_revision = '12262aadbc08'
revision = "48af31eb6f3f"
down_revision = "12262aadbc08"
branch_labels = None
depends_on = None
def upgrade():
op.create_unique_constraint(op.f('uq_fingerprints_date'), 'fingerprints', ['date', 'employee_id'])
op.create_unique_constraint(op.f("uq_fingerprints_date"), "fingerprints", ["date", "employee_id"])
def downgrade():
op.drop_constraint(op.f('uq_fingerprints_date'), 'fingerprints', type_='unique')
op.drop_constraint(op.f("uq_fingerprints_date"), "fingerprints", type_="unique")

View File

@ -8,8 +8,9 @@ Create Date: 2023-03-31 05:03:40.408240
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from sqlalchemy import func
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "a1372ed99c45"
@ -62,15 +63,15 @@ def upgrade():
op.add_column("recipes", sa.Column("garnishing", sa.Text(), nullable=False, server_default=""))
op.add_column("recipes", sa.Column("plating", sa.Text(), nullable=False, server_default=""))
op.drop_constraint(op.f("uq_recipes_sku_id"), "recipes", type_="unique")
op.alter_column("recipes", "notes", existing_type=sa.VARCHAR(length=255),type=sa.Text(), nullable=False)
op.create_unique_constraint(op.f('uq_recipes_sku_id'), 'recipes', ['sku_id', 'date'])
op.alter_column("recipes", "notes", existing_type=sa.VARCHAR(length=255), type=sa.Text(), nullable=False)
op.create_unique_constraint(op.f("uq_recipes_sku_id"), "recipes", ["sku_id", "date"])
op.create_index(op.f("ix_recipes_date"), "recipes", ["date"], unique=False)
op.drop_constraint("fk_recipes_period_id_periods", "recipes", type_="foreignkey")
op.drop_column("recipes", "period_id")
op.drop_column('recipes', 'sale_price')
op.drop_column('recipes', 'cost_price')
op.add_column('recipe_items', sa.Column('description', sa.Text(), nullable=False, server_default=""))
op.drop_column('recipe_items', 'price')
op.drop_column("recipes", "sale_price")
op.drop_column("recipes", "cost_price")
op.add_column("recipe_items", sa.Column("description", sa.Text(), nullable=False, server_default=""))
op.drop_column("recipe_items", "price")
op.alter_column("role_permissions", "permission_id", existing_type=sa.UUID(), nullable=False)
op.alter_column("role_permissions", "role_id", existing_type=sa.UUID(), nullable=False)
op.create_unique_constraint(

View File

@ -0,0 +1,39 @@
"""price
Revision ID: ba0fff092981
Revises: 48af31eb6f3f
Create Date: 2023-08-11 18:12:51.293741
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "ba0fff092981"
down_revision = "48af31eb6f3f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"prices",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("period_id", sa.Uuid(), nullable=False),
sa.Column("product_id", sa.Uuid(), nullable=False),
sa.Column("price", sa.Numeric(precision=15, scale=2), nullable=False),
sa.ForeignKeyConstraint(["period_id"], ["periods.id"], name=op.f("fk_prices_period_id_periods")),
sa.ForeignKeyConstraint(["product_id"], ["products.id"], name=op.f("fk_prices_product_id_products")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_prices")),
sa.UniqueConstraint("period_id", "product_id", name=op.f("uq_prices_period_id")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("prices")
# ### end Alembic commands ###

View File

@ -19,6 +19,7 @@ from ..models.journal import Journal # noqa: F401
from ..models.login_history import LoginHistory # noqa: F401
from ..models.period import Period # noqa: F401
from ..models.permission import Permission # noqa: F401
from ..models.price import Price # noqa: F401
from ..models.product import Product # noqa: F401
from ..models.product_group import ProductGroup # noqa: F401
from ..models.rate_contract import RateContract # noqa: F401

View File

@ -0,0 +1,51 @@
from __future__ import annotations
import uuid
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Numeric, UniqueConstraint, Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..db.base_class import reg
if TYPE_CHECKING:
from .period import Period
from .product import Product
@reg.mapped_as_dataclass(unsafe_hash=True)
class Price:
__tablename__ = "prices"
__table_args__ = (UniqueConstraint("period_id", "product_id"),)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, insert_default=uuid.uuid4)
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("periods.id"), nullable=False)
product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False)
period: Mapped["Period"] = relationship("Period")
product: Mapped["Product"] = relationship("Product")
def __init__(
self,
price: Decimal,
period_id: uuid.UUID | None = None,
product_id: uuid.UUID | None = None,
period: "Period" | None = None,
product: "Product" | None = None,
id_: uuid.UUID | None = None,
):
self.price = price
if period_id is not None:
self.period_id = period_id
if product_id is not None:
self.product_id = product_id
if period is not None and (period.id is not None or period_id is None):
self.period = period
if product is not None and (product.id is not None or product_id is None):
self.product = product
if id_ is not None:
self.id = id_

View File

@ -0,0 +1,131 @@
import uuid
from decimal import Decimal
from brewman.models.batch import Batch
from brewman.models.cost_centre import CostCentre
from brewman.models.inventory import Inventory
from brewman.models.journal import Journal
from brewman.models.period import Period
from brewman.models.price import Price
from brewman.models.voucher import Voucher
from brewman.models.voucher_type import VoucherType
from fastapi import HTTPException, status
from sqlalchemy import distinct, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from ..models.recipe import Recipe
from ..models.recipe_item import RecipeItem
from ..models.stock_keeping_unit import StockKeepingUnit
def calculate_prices(period_id: uuid.UUID, db: Session):
try:
item: Period = db.execute(select(Period).where(Period.id == period_id)).scalar_one()
recipes = set(
db.execute(select(distinct(StockKeepingUnit.product_id)).join(StockKeepingUnit.recipes)).scalars().all()
)
ingredients = set(db.execute(select(distinct(RecipeItem.product_id))).scalars().all())
issued_products = get_issue_prices(item, ingredients - recipes, db)
left = ingredients - recipes - issued_products.keys()
purchased_products = get_issue_prices(item, left, db)
left -= purchased_products.keys()
rest = get_rest(left, db)
prices = issued_products | purchased_products | rest
while len(recipes) > 0:
calculate_recipes(recipes, prices, db)
for pid, price in prices.items():
db.execute(
pg_insert(Price)
.values(product_id=pid, price=price, period_id=item.id)
.on_conflict_do_update(constraint="uq_prices_period_id", set_=dict(price=price))
)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
def get_issue_prices(period: Period, products: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]:
sum_quantity = func.sum(
Inventory.quantity * StockKeepingUnit.fraction * StockKeepingUnit.product_yield * Journal.debit
).label("quantity")
sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net")
d: dict[uuid.UUID, Decimal] = {}
query: list[tuple[uuid.UUID, Decimal]] = db.execute(
select(StockKeepingUnit.product_id, sum_net / sum_quantity)
.join(Inventory.batch)
.join(Batch.sku)
.join(Inventory.voucher)
.join(Voucher.journals)
.where(
Voucher.date_ >= period.valid_from,
Voucher.date_ <= period.valid_till,
Voucher.voucher_type.in_([VoucherType.ISSUE, VoucherType.CLOSING_STOCK]),
Journal.cost_centre_id != CostCentre.cost_centre_purchase(),
StockKeepingUnit.product_id.in_(products),
)
.group_by(StockKeepingUnit.product_id, Journal.debit)
).all()
for id, amount in query:
d[id] = amount
return d
def get_purchase_prices(period: Period, req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]:
sum_quantity = func.sum(
Inventory.quantity * StockKeepingUnit.fraction * StockKeepingUnit.product_yield * Journal.debit
).label("quantity")
sum_net = func.sum(Inventory.rate * Inventory.quantity * Journal.debit).label("net")
d: dict[uuid.UUID, Decimal] = {}
query: list[tuple[uuid.UUID, Decimal]] = db.execute(
select(StockKeepingUnit.product_id, sum_net / sum_quantity)
.join(Inventory.batch)
.join(Batch.sku)
.join(Inventory.voucher)
.join(Voucher.journals)
.where(
Voucher.date_ >= period.valid_from,
Voucher.date_ <= period.valid_till,
Voucher.voucher_type == VoucherType.PURCHASE,
StockKeepingUnit.product_id.in_(req),
)
.group_by(StockKeepingUnit.product_id, Journal.debit)
).all()
for id, amount in query:
d[id] = amount
return d
def get_rest(req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]:
d: dict[uuid.UUID, Decimal] = {}
query = db.execute(
select(
StockKeepingUnit.product_id,
StockKeepingUnit.cost_price / (StockKeepingUnit.fraction * StockKeepingUnit.product_yield),
).where(StockKeepingUnit.product_id.in_(req))
).all()
for id, amount in query:
d[id] = amount
return d
def calculate_recipes(recipes: set[uuid.UUID], prices: dict[uuid.UUID, Decimal], db: Session) -> None:
sq = select(RecipeItem.recipe_id).where(RecipeItem.product_id.in_(recipes))
items = (
db.execute(
select(Recipe).join(Recipe.sku).where(StockKeepingUnit.product_id.in_(recipes), Recipe.id.notin_(sq))
)
.scalars()
.all()
)
for item in items:
cost = sum(i.quantity * prices[i.product_id] for i in item.items) / (item.recipe_yield * item.sku.fraction)
prices[item.sku.product_id] = cost
recipes.remove(item.sku.product_id)

View File

@ -11,6 +11,8 @@ from typing import Sequence
import brewman.schemas.recipe as schemas
import brewman.schemas.recipe_item as rischemas
from brewman.models.price import Price
from brewman.routers.calculate_prices import calculate_prices
from fastapi import APIRouter, Depends, HTTPException, Request, Security, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook
@ -72,8 +74,8 @@ def save(
r_item.recipe_id = recipe.id
db.add(r_item)
# TODO: Check recursion
# check_recursion(set([recipe_sku.product_id]), set(), recipe, db)
db.flush()
check_recursion(recipe_sku.product_id, set(), db)
db.commit()
return recipe_info(recipe)
except SQLAlchemyError as e:
@ -130,7 +132,8 @@ async def update_route(
RecipeItem(product_id=product.id, quantity=quantity, description=d_item.description)
)
check_recursion(set([sku.product_id]), set(), db)
db.flush()
check_recursion(sku.product_id, set(), db)
db.commit()
return recipe_info(recipe)
except SQLAlchemyError as e:
@ -140,26 +143,23 @@ async def update_route(
)
def check_recursion(products: set[uuid.UUID], visited: set[uuid.UUID], db: Session) -> None:
sq = (
select(func.distinct(RecipeItem.product_id))
.join(Recipe.items)
.join(Recipe.sku)
.where(StockKeepingUnit.product_id.in_(products))
)
ingredient_product_ids = (
db.execute(select(StockKeepingUnit.product_id).join(Recipe.sku).where(StockKeepingUnit.product_id.in_(sq)))
.scalars()
.all()
)
if len(ingredient_product_ids) == 0:
return
if (visited | products) & set(ingredient_product_ids):
def check_recursion(product: uuid.UUID, visited: set[uuid.UUID], db: Session) -> None:
if product in visited:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Recipe recursion. Some ingredient recipe contains parent recipe.",
)
check_recursion(set(ingredient_product_ids), visited | products, db)
recipe: Recipe = (
db.execute(select(Recipe).join(Recipe.items).join(Recipe.sku).where(StockKeepingUnit.product_id == product))
.unique()
.scalar_one_or_none()
)
if recipe is None:
return
visited.add(product)
for i in recipe.items:
check_recursion(i.product_id, visited, db)
visited.remove(product)
@router.delete("/{id_}", response_model=None)
@ -169,7 +169,6 @@ def delete_route(
user: UserToken = Security(get_user, scopes=["recipes"]),
) -> None:
with SessionFuture() as db:
recipe: Recipe = db.execute(select(Recipe).where(Recipe.id == id_)).scalar_one()
recipe_ids: Sequence[uuid.UUID] = (
db.execute(
select(func.distinct(RecipeItem.recipe_id)).where(
@ -268,7 +267,22 @@ def show_pdf(
@router.get("/xlsx", response_class=StreamingResponse)
def get_report(
p: uuid.UUID | None = None,
t: uuid.UUID | None = None,
) -> StreamingResponse:
with SessionFuture() as db:
calculate_prices(t, db)
db.commit()
prices: list[tuple[str, str, Decimal]] = []
with SessionFuture() as db:
pq = (
db.execute(select(Price).where(Price.period_id == t).options(joinedload(Price.product, innerjoin=True)))
.unique()
.scalars()
.all()
)
prices = [(i.product.name, i.product.fraction_units, i.price) for i in pq]
list_: Sequence[Recipe] = []
with SessionFuture() as db:
q = (
select(Recipe)
@ -282,17 +296,25 @@ def get_report(
)
if p is not None:
q = q.where(Recipe.sku, StockKeepingUnit.product, Product.product_group_id == p)
list_: Sequence[Recipe] = db.execute(q).unique().scalars().all()
e = excel(sorted(list_, key=lambda r: r.sku.product.name))
e.seek(0)
list_ = db.execute(q).unique().scalars().all()
e = excel(prices, sorted(list_, key=lambda r: r.sku.product.name))
e.seek(0)
headers = {"Content-Disposition": "attachment; filename = recipe.xlsx"}
return StreamingResponse(e, media_type="text/xlsx", headers=headers)
headers = {"Content-Disposition": "attachment; filename = recipe.xlsx"}
return StreamingResponse(e, media_type="text/xlsx", headers=headers)
def excel(recipes: list[Recipe]) -> BytesIO:
def excel(prices: list[tuple[str, str, Decimal, Decimal, Decimal]], recipes: list[Recipe]) -> BytesIO:
wb = Workbook()
wb.active.title = "Rate List"
wb.active.cell(row=1, column=1, value="Name")
wb.active.cell(row=1, column=2, value="Units")
wb.active.cell(row=1, column=3, value="Rate")
for i, p in enumerate(prices, start=2):
wb.active.cell(row=i, column=1, value=p[0])
wb.active.cell(row=i, column=2, value=p[1])
wb.active.cell(row=i, column=3, value=p[2])
pgs = set([x.sku.product.product_group.name for x in recipes])
for pg in pgs:
wb.create_sheet(pg)
@ -301,6 +323,7 @@ def excel(recipes: list[Recipe]) -> BytesIO:
for recipe in recipes:
ws = wb[recipe.sku.product.product_group.name]
row = rows[recipe.sku.product.product_group.name]
print(row)
ings = len(recipe.items)
ing_from = row + 2
ing_till = ing_from + ings - 1
@ -321,11 +344,11 @@ def excel(recipes: list[Recipe]) -> BytesIO:
ws.cell(row=row, column=1, value=item.product.name).style = "ing"
ws.cell(row=row, column=2, value=item.product.fraction_units).style = "unit"
ws.cell(row=row, column=3, value=item.quantity).style = "ing"
ws.cell(row=row, column=4, value="=VLOOKUP(A:A,'Rate List'!A:G,7,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"
rows[recipe.sku.product.product_group.name] = row + 1
virtual_workbook = BytesIO()
wb.save(virtual_workbook)
virtual_workbook = BytesIO()
wb.save(virtual_workbook)
return virtual_workbook

View File

@ -2,7 +2,7 @@
<mat-card-header>
<mat-card-title-group>
<mat-card-title>Recipes</mat-card-title>
<a mat-icon-button href="{{ excelLink() }}">
<a mat-icon-button [href]="'/api/recipes/xlsx?t=' + period.id">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-button [routerLink]="['/recipes', 'new']">

View File

@ -31,6 +31,7 @@ export class RecipeListComponent implements OnInit {
list: Recipe[] = [];
data: BehaviorSubject<Recipe[]> = new BehaviorSubject<Recipe[]>([]);
dataSource: RecipeListDatasource = new RecipeListDatasource(this.productGroupFilter, this.data);
period: Period = new Period();
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'yield', 'date', 'source'];
@ -44,13 +45,14 @@ export class RecipeListComponent implements OnInit {
productGroup: new FormControl<ProductGroup | string | null>(null),
});
// Listen to Payment Account Change
this.form.controls.period.valueChanges.subscribe((x) =>
this.form.controls.period.valueChanges.subscribe((x) => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { p: x.id },
replaceUrl: true,
}),
);
});
this.period = x;
});
}
ngOnInit() {
@ -73,8 +75,4 @@ export class RecipeListComponent implements OnInit {
filterProductGroup(val: string) {
this.productGroupFilter.next(val || '');
}
excelLink() {
return `/api/recipes/xlsx`;
}
}