diff --git a/brewman/alembic/versions/12262aadbc08_recipe_templates.py b/brewman/alembic/versions/12262aadbc08_recipe_templates.py index fdf4c2e9..782846ea 100644 --- a/brewman/alembic/versions/12262aadbc08_recipe_templates.py +++ b/brewman/alembic/versions/12262aadbc08_recipe_templates.py @@ -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 ### diff --git a/brewman/alembic/versions/48af31eb6f3f_fp.py b/brewman/alembic/versions/48af31eb6f3f_fp.py index eafa00e4..0ae5e018 100644 --- a/brewman/alembic/versions/48af31eb6f3f_fp.py +++ b/brewman/alembic/versions/48af31eb6f3f_fp.py @@ -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") diff --git a/brewman/alembic/versions/a1372ed99c45_recipe_upgrade.py b/brewman/alembic/versions/a1372ed99c45_recipe_upgrade.py index ebc52ec0..9147e6c4 100644 --- a/brewman/alembic/versions/a1372ed99c45_recipe_upgrade.py +++ b/brewman/alembic/versions/a1372ed99c45_recipe_upgrade.py @@ -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( diff --git a/brewman/alembic/versions/ba0fff092981_price.py b/brewman/alembic/versions/ba0fff092981_price.py new file mode 100644 index 00000000..42d47945 --- /dev/null +++ b/brewman/alembic/versions/ba0fff092981_price.py @@ -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 ### diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index 80e41a52..ad82c9fd 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -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 diff --git a/brewman/brewman/models/price.py b/brewman/brewman/models/price.py new file mode 100644 index 00000000..8755fcca --- /dev/null +++ b/brewman/brewman/models/price.py @@ -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_ diff --git a/brewman/brewman/routers/calculate_prices.py b/brewman/brewman/routers/calculate_prices.py new file mode 100644 index 00000000..c6c51325 --- /dev/null +++ b/brewman/brewman/routers/calculate_prices.py @@ -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) diff --git a/brewman/brewman/routers/recipe.py b/brewman/brewman/routers/recipe.py index b5539c90..a8bdf647 100644 --- a/brewman/brewman/routers/recipe.py +++ b/brewman/brewman/routers/recipe.py @@ -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 diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.html b/overlord/src/app/recipe/recipe-list/recipe-list.component.html index a4f73081..8a9bb52b 100644 --- a/overlord/src/app/recipe/recipe-list/recipe-list.component.html +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.html @@ -2,7 +2,7 @@ Recipes - + save_alt diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.ts b/overlord/src/app/recipe/recipe-list/recipe-list.component.ts index 3c99e0a6..d5ddd227 100644 --- a/overlord/src/app/recipe/recipe-list/recipe-list.component.ts +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.ts @@ -31,6 +31,7 @@ export class RecipeListComponent implements OnInit { list: Recipe[] = []; data: BehaviorSubject = new BehaviorSubject([]); 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(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`; - } }