diff --git a/brewman/alembic/versions/0bfb24f16ef2_recipe_validity_not_null.py b/brewman/alembic/versions/0bfb24f16ef2_recipe_validity_not_null.py new file mode 100644 index 00000000..18772157 --- /dev/null +++ b/brewman/alembic/versions/0bfb24f16ef2_recipe_validity_not_null.py @@ -0,0 +1,26 @@ +"""recipe validity not null + +Revision ID: 0bfb24f16ef2 +Revises: b13dbf97bd21 +Create Date: 2021-11-10 07:39:05.369603 + +""" +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "0bfb24f16ef2" +down_revision = "b13dbf97bd21" +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column("recipes", "valid_from", existing_type=sa.DATE(), nullable=False) + op.alter_column("recipes", "valid_till", existing_type=sa.DATE(), nullable=False) + + +def downgrade(): + pass diff --git a/brewman/brewman/core/session.py b/brewman/brewman/core/session.py index fb0632e2..7e272963 100644 --- a/brewman/brewman/core/session.py +++ b/brewman/brewman/core/session.py @@ -25,8 +25,10 @@ def get_finish_date(session: dict) -> str: def set_period(start, finish, session: dict): - session["start"] = start if isinstance(start, str) else start.strftime("%d-%b-%Y") - session["finish"] = finish if isinstance(finish, str) else finish.strftime("%d-%b-%Y") + if start is not None: + session["start"] = start if isinstance(start, str) else start.strftime("%d-%b-%Y") + if finish is not None: + session["finish"] = finish if isinstance(finish, str) else finish.strftime("%d-%b-%Y") def get_first_day(dt: date, d_years=0, d_months=0) -> date: diff --git a/brewman/brewman/models/batch.py b/brewman/brewman/models/batch.py index 947b81ec..a7aa9544 100644 --- a/brewman/brewman/models/batch.py +++ b/brewman/brewman/models/batch.py @@ -2,14 +2,13 @@ import uuid from datetime import date from decimal import Decimal -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List -from sqlalchemy import Column, Date, ForeignKey, Numeric, select +from sqlalchemy import Column, Date, ForeignKey, Numeric from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, Session, contains_eager, relationship +from sqlalchemy.orm import Mapped, relationship from .meta import Base -from .product import Product if TYPE_CHECKING: diff --git a/brewman/brewman/models/recipe.py b/brewman/brewman/models/recipe.py index 7eef0f15..233fc72e 100644 --- a/brewman/brewman/models/recipe.py +++ b/brewman/brewman/models/recipe.py @@ -29,8 +29,8 @@ class Recipe(Base): sale_price: Decimal = Column("sale_price", Numeric(precision=15, scale=2), nullable=False) notes: str = Column("notes", Unicode(255)) - valid_from: datetime.date = Column("valid_from", Date, nullable=True) - valid_till: datetime.date = Column("valid_till", Date, nullable=True) + valid_from: datetime.date = Column("valid_from", Date, nullable=False) + valid_till: datetime.date = Column("valid_till", Date, nullable=False) items: List["RecipeItem"] = relationship("RecipeItem", back_populates="recipe") sku: Mapped["StockKeepingUnit"] = relationship("StockKeepingUnit", back_populates="recipes") @@ -44,6 +44,7 @@ class Recipe(Base): valid_from=None, valid_till=None, notes=None, + sku=None, id_=None, ): self.sku_id = sku_id @@ -54,3 +55,8 @@ class Recipe(Base): self.valid_till = valid_till self.notes = "" if notes is None else notes self.id = id_ + if sku is None: + self.sku_id = sku_id + else: + self.sku_id = sku.id + self.sku = sku diff --git a/brewman/brewman/models/recipe_item.py b/brewman/brewman/models/recipe_item.py index 5ae65ddf..85351f93 100644 --- a/brewman/brewman/models/recipe_item.py +++ b/brewman/brewman/models/recipe_item.py @@ -2,7 +2,7 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import Column, ForeignKey, Integer, Numeric, UniqueConstraint +from sqlalchemy import Column, ForeignKey, Numeric, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, relationship diff --git a/brewman/brewman/routers/batch.py b/brewman/brewman/routers/batch.py index f7c05405..94b68648 100644 --- a/brewman/brewman/routers/batch.py +++ b/brewman/brewman/routers/batch.py @@ -1,5 +1,9 @@ import datetime +from typing import List + +import brewman.schemas.batch as schemas + from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import contains_eager @@ -15,14 +19,14 @@ from ..schemas.user import UserToken router = APIRouter() -@router.get("") +@router.get("", response_model=List[schemas.Batch]) def batch_term( q: str, d: str, current_user: UserToken = Depends(get_user), -): +) -> List[schemas.Batch]: date_ = datetime.datetime.strptime(d, "%d-%b-%Y") - list_ = [] + list_: List[schemas.Batch] = [] with SessionFuture() as db: query = ( select(Batch) @@ -32,8 +36,8 @@ def batch_term( .options(contains_eager(Batch.sku).contains_eager(StockKeepingUnit.product)) ) if q is not None: - for item in q.split(): - query = query.where(Product.name.ilike(f"%{item}%")) + for q_item in q.split(): + query = query.where(Product.name.ilike(f"%{q_item}%")) result = db.execute(query.order_by(Product.name).order_by(Batch.name)).scalars().all() for item in result: @@ -42,17 +46,17 @@ def batch_term( f"{item.rate:.2f} from {item.name.strftime('%d-%b-%Y')}" ) list_.append( - { - "id": item.id, - "name": text, - "quantityRemaining": round(item.quantity_remaining, 2), - "rate": round(item.rate, 2), - "tax": round(item.tax, 5), - "discount": round(item.discount, 5), - "sku": { - "id": item.sku.id, - "name": f"{item.sku.product.name} ({item.sku.units})", - }, - } + schemas.Batch( + id=item.id, + name=text, + quantityRemaining=round(item.quantity_remaining, 2), + rate=round(item.rate, 2), + tax=round(item.tax, 5), + discount=round(item.discount, 5), + sku=schemas.ProductLink( + id=item.sku.id, + name=f"{item.sku.product.name} ({item.sku.units})", + ), + ) ) return list_ diff --git a/brewman/brewman/routers/incentive.py b/brewman/brewman/routers/incentive.py index 62e4360b..7b3ffb57 100644 --- a/brewman/brewman/routers/incentive.py +++ b/brewman/brewman/routers/incentive.py @@ -278,7 +278,7 @@ def balance(date_: date, voucher_id: Optional[uuid.UUID], db: Session): ) if voucher_id is not None: amount = amount.where(Voucher.id != voucher_id) - result: Decimal = db.execute(amount).scalar_one_or_none() + result: Optional[Decimal] = db.execute(amount).scalar_one_or_none() return 0 if result is None else result * -1 @@ -287,19 +287,19 @@ def check_if_employees_changed( db: List[Employee], voucher: Optional[List[Journal]], ): - json = set(x.employee_id for x in json) - db = set(x.id for x in db) - voucher = ( + json_ids = set(x.employee_id for x in json) + db_ids = set(x.id for x in db) + voucher_ids = ( set(x.account_id for x in voucher if x.account_id != Account.incentive_id()) if voucher is not None else None ) - if voucher is None: - if len(json ^ db) != 0: + if voucher_ids is None: + if len(json_ids ^ db_ids) != 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Employee missing in json data", ) else: - if len(json ^ db) != 0 or len(db ^ voucher) != 0: + if len(json_ids ^ db_ids) != 0 or len(db_ids ^ voucher_ids) != 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Employee missing in json data", diff --git a/brewman/brewman/routers/issue.py b/brewman/brewman/routers/issue.py index d0a084d3..b276b5b7 100644 --- a/brewman/brewman/routers/issue.py +++ b/brewman/brewman/routers/issue.py @@ -97,6 +97,8 @@ def save(data: schema_in.IssueIn, user: UserToken, db: Session) -> Tuple[Voucher status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Source cannot be the same as destination", ) + + batch_consumed: Optional[bool] if data.source.id_ == CostCentre.cost_centre_purchase(): batch_consumed = True elif data.destination.id_ == CostCentre.cost_centre_purchase(): @@ -241,6 +243,7 @@ def update_voucher( else: source = item.cost_centre_id + old_batch_consumed: Optional[bool] if source == CostCentre.cost_centre_purchase(): old_batch_consumed = True elif destination == CostCentre.cost_centre_purchase(): @@ -248,6 +251,7 @@ def update_voucher( else: old_batch_consumed = None + new_batch_consumed: Optional[bool] if data.source.id_ == CostCentre.cost_centre_purchase(): new_batch_consumed = True elif data.destination.id_ == CostCentre.cost_centre_purchase(): diff --git a/brewman/brewman/routers/journal.py b/brewman/brewman/routers/journal.py index 9ec0374a..1f32c2c5 100644 --- a/brewman/brewman/routers/journal.py +++ b/brewman/brewman/routers/journal.py @@ -202,6 +202,6 @@ def show_blank( additional_info = BlankVoucherInfo(date=get_date(request.session), type=type_) if a: - additional_info.ac = AccountLink(id=a) + additional_info.account = AccountLink(id=a) with SessionFuture() as db: return blank_voucher(additional_info, db) diff --git a/brewman/brewman/routers/purchase.py b/brewman/brewman/routers/purchase.py index 5283c9eb..63b9ae8a 100644 --- a/brewman/brewman/routers/purchase.py +++ b/brewman/brewman/routers/purchase.py @@ -285,9 +285,12 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, inventories: List[I db.flush() fix_single_batch_prices(item.batch_id, db) else: - has_been_issued: int = db.execute( - select(func.count(Inventory.id)).where(Inventory.batch_id == item.batch.id, Inventory.id != item.id) - ).scalar() + has_been_issued: int = ( + db.execute( + select(func.count(Inventory.id)).where(Inventory.batch_id == item.batch.id, Inventory.id != item.id) + ) + .scalar_one() + ) if has_been_issued > 0: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/brewman/brewman/routers/recipe.py b/brewman/brewman/routers/recipe.py index 81812804..9ad1192d 100644 --- a/brewman/brewman/routers/recipe.py +++ b/brewman/brewman/routers/recipe.py @@ -1,27 +1,30 @@ import uuid from datetime import date, timedelta -from typing import List +from decimal import Decimal +from typing import List, Optional, Set import brewman.schemas.recipe as schemas import brewman.schemas.recipe_item from fastapi import APIRouter, Depends, HTTPException, Request, Security, status -from sqlalchemy import desc, func, or_, select -from sqlalchemy.orm import Session +from sqlalchemy import delete, desc, func, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, contains_eager from ..core.security import get_current_active_user as get_user from ..core.session import get_finish_date, get_start_date, set_period from ..db.session import SessionFuture -from ..models.cost_centre import CostCentre +from ..models.batch import Batch from ..models.inventory import Inventory -from ..models.journal import Journal from ..models.product import Product from ..models.recipe import Recipe from ..models.recipe_item import RecipeItem +from ..models.stock_keeping_unit import StockKeepingUnit from ..models.voucher import Voucher -from ..models.voucher_type import VoucherType +from ..schemas.product_sku import ProductSku from ..schemas.user import UserToken +from .reports import report_finish_date, report_start_date router = APIRouter() @@ -32,153 +35,248 @@ router = APIRouter() def save( data: schemas.RecipeIn, request: Request, - user: UserToken = Security(get_user), + user: UserToken = Security(get_user, scopes=["recipes"]), ) -> schemas.Recipe: - with SessionFuture() as db: - recipe_product: Product = db.execute(select(Product).where(Product.id == data.product.id_)).scalar_one() + try: + with SessionFuture() as db: + recipe_sku: StockKeepingUnit = db.execute( + select(StockKeepingUnit).where(StockKeepingUnit.id == data.sku.id_) + ).scalar_one() - if data.valid_till < data.valid_from: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Valid Till cannot be less than valid from", - ) - sale_price = 0 - recipe_cost = 0 - if len(data.items) == 0: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Recipe has no ingredients", + if data.valid_till < data.valid_from: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Valid Till cannot be less than valid from", + ) + conflicting = db.execute( + select(Recipe.id).where( + Recipe.sku_id == data.sku.id_, + Recipe.valid_from <= data.valid_till, + Recipe.valid_till >= data.valid_from, + ) + ).scalar_one_or_none() + if conflicting is not None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Recipe of the same product for the same period exists.", + ) + if recipe_sku.product_id in set(i.product.id_ for i in data.items): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Product cannot also be an ingredient", + ) + if len(data.items) == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Recipe has no ingredients", + ) + + recipe = Recipe( + sku=recipe_sku, + recipe_yield=round(data.recipe_yield, 2), + sale_price=round(data.sale_price, 2), + valid_from=data.valid_from, + valid_till=data.valid_till, ) + recipe_cost = Decimal(0) + for item in data.items: + product: Product = db.execute(select(Product).where(Product.id == item.product.id_)).scalar_one() + quantity = round(item.quantity, 2) + if product.is_purchased: + ingredient_cost = get_purchased_product_cost(product, data.valid_from, data.valid_till, db) + else: + ingredient_cost = get_sub_product_cost(product, data.valid_from, data.valid_till, db) + # ingredient_cost = get_purchased_product_cost(product.id, data.valid_from, data.valid_till, db) + recipe_cost += round(ingredient_cost * quantity, 2) + recipe.items.append(RecipeItem(None, product.id, quantity, ingredient_cost)) - recipe = Recipe( - product_id=data.product.id_, - quantity=round(data.quantity, 2), - sale_price=round(data.sale_price, 2), - valid_from=data.valid_from, - valid_till=data.valid_till, + recipe.cost_price = round(recipe_cost, 2) + recipe_sku.cost_price = round(recipe.cost_price / recipe.recipe_yield, 2) + if recipe_sku.product.is_sold: + recipe_sku.sale_price = recipe.sale_price + + db.add(recipe) + for item in recipe.items: + item.recipe_id = recipe.id + db.add(item) + + seen_recipes: Set[uuid.UUID] = set() + check_recursion(recipe, seen_recipes, db) + db.commit() + set_period(data.valid_from, data.valid_till, request.session) + return recipe_info(recipe) + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), ) - for item in data.items: - product: Product = db.execute(select(Product).where(Product.id == item.product.id_)).scalar_one() - quantity = round(data.quantity, 2) - if product.is_purchased: - ingredient_cost = get_purchased_product_cost(product.id, data.valid_from, data.valid_till, db) - else: - ingredient_cost = get_sub_product_cost(product.id, data.valid_from, data.valid_till, db) - cost_per_unit = ingredient_cost / (product.fraction * product.product_yield) - recipe_cost += cost_per_unit * quantity - recipe.recipe_items.append(RecipeItem(None, product.id, quantity, ingredient_cost)) - - recipe.cost_price = round(recipe_cost / recipe.quantity, 2) - if recipe_product.is_sold: - recipe_product.sale_price = sale_price - - save_recipe(recipe, db) - db.commit() - set_period(data.valid_from, data.valid_till, request.session) - return recipe_info(recipe) -def save_recipe(recipe: Recipe, db: Session): - product: Product = db.execute(select(Product).where(Product.id == recipe.product_id)).scalar_one() - product.price = recipe.cost_price - update_old_rows( - recipe.product_id, - recipe.valid_from, - recipe.valid_till, - recipe.effective_from, - db, - ) - db.add(recipe) - for item in recipe.recipe_items: - item.recipe_id = recipe.id - db.add(item) +@router.put("/{id_}", response_model=schemas.Recipe) +async def update_route( + id_: uuid.UUID, + data: schemas.RecipeIn, + request: Request, + user: UserToken = Security(get_user, scopes=["recipes"]), +) -> schemas.Recipe: + try: + with SessionFuture() as db: + recipe: Recipe = db.execute(select(Recipe).where(Recipe.id == id_)).scalar_one() + if data.valid_till < data.valid_from: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Valid Till cannot be less than valid from", + ) + conflicting = db.execute( + select(Recipe.id).where( + Recipe.sku_id == data.sku.id_, + Recipe.id != id_, + Recipe.valid_from <= data.valid_till, + Recipe.valid_till >= data.valid_from, + ) + ).scalar_one_or_none() + if conflicting is not None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Recipe of the same product for the same period exists.", + ) -def get_purchased_product_cost(product_id: uuid.UUID, start_date: date, finish_date: date, db: Session): - quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") - amount_sum = func.sum( - Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax) * (1 - Inventory.discount) - ).label("amount") - costing = db.execute( - select(quantity_sum, amount_sum) - .join(Product.inventories) - .join(Inventory.voucher) - .join(Voucher.journals) - .where( - Inventory.product_id == product_id, - Voucher.date >= start_date, - Voucher.date <= finish_date, - Voucher.voucher_type == VoucherType.ISSUE, - Journal.cost_centre_id == CostCentre.cost_centre_purchase(), + sku: StockKeepingUnit = db.execute( + select(StockKeepingUnit).where(StockKeepingUnit.id == data.sku.id_) + ).scalar_one() + if sku.product_id in set(i.product.id_ for i in data.items): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Product cannot also be an ingredient", + ) + recipe.sku = sku + recipe.recipe_yield = round(data.recipe_yield, 2) + recipe.sale_price = round(data.sale_price, 2) + recipe.valid_from = data.valid_from + recipe.valid_till = data.valid_till + + recipe_cost = Decimal(0) + + for i in range(len(recipe.items), 0, -1): + item = recipe.items[i - 1] + index = next((idx for (idx, d) in enumerate(data.items) if d.id_ == item.id), None) + product: Product + if index is not None: + new_item = data.items.pop(index) + product = db.execute(select(Product).where(Product.id == new_item.product.id_)).scalar_one() + item.product_id = new_item.product.id_ + item.quantity = round(new_item.quantity, 2) + if product.is_purchased: + ingredient_cost = get_purchased_product_cost(product, data.valid_from, data.valid_till, db) + else: + ingredient_cost = get_sub_product_cost(product, data.valid_from, data.valid_till, db) + # ingredient_cost = get_purchased_product_cost(product.id, data.valid_from, data.valid_till, db) + item.price = ingredient_cost + recipe_cost += round(ingredient_cost * item.quantity, 2) + else: + recipe.items.remove(item) + + for item in data.items: + product = db.execute(select(Product).where(Product.id == item.product.id_)).scalar_one() + quantity = round(item.quantity, 2) + if product.is_purchased: + ingredient_cost = get_purchased_product_cost(product, data.valid_from, data.valid_till, db) + else: + ingredient_cost = get_sub_product_cost(product, data.valid_from, data.valid_till, db) + # ingredient_cost = get_purchased_product_cost(product.id, data.valid_from, data.valid_till, db) + + recipe_cost += round(ingredient_cost * quantity, 2) + recipe.items.append(RecipeItem(None, product.id, quantity, ingredient_cost)) + + recipe.cost_price = recipe_cost + seen_recipes: Set[uuid.UUID] = set() + check_recursion(recipe, seen_recipes, db) + db.commit() + set_period(data.valid_from, data.valid_till, request.session) + return recipe_info(recipe) + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), ) - .group_by(Product) - ).one_or_none() - if costing is None: - product = db.execute(select(Product).where(Product.id == product_id)).scalar_one() - return product.price - else: - quantity, amount = costing - return amount / quantity -def get_sub_product_cost(product_id: uuid.UUID, start_date: date, finish_date: date, db: Session): - product: Product = db.execute(select(Product).where(Product.id == product_id)).scalar_one() - old_recipe = ( - db.execute( - select(Recipe).where( - Recipe.product_id == product_id, - Recipe.effective_to == None, - Recipe.valid_from == start_date, - Recipe.valid_till == finish_date, - ) - ) - .scalars() - .one_or_none() - ) - - if old_recipe is not None: - return old_recipe.cost_price - - old_recipe = ( - db.execute( - select(Recipe) - .where(Recipe.product_id == product_id, Recipe.effective_to == None) - .order_by(desc(Recipe.valid_till)) - ) - .scalars() - .one_or_none() - ) - - if old_recipe is None: +def check_recursion(recipe: Recipe, seen_recipes: Set[uuid.UUID], db: Session) -> None: + if recipe.sku.product_id in seen_recipes: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Ingredient XXX has no recipe, cannot determine cost", + detail="Recipe recursion. Some ingredient recipe contains parent recipe.", + ) + seen_recipes.add(recipe.sku.product_id) + for item in recipe.items: + item_recipes: List[Recipe] = ( + db.execute( + select(Recipe) + .join(Recipe.sku) + .join(Recipe.items) + .where( + StockKeepingUnit.product_id == item.product_id, + Recipe.valid_from <= recipe.valid_till, + Recipe.valid_till >= recipe.valid_from, + ) + .options(contains_eager(Recipe.sku), contains_eager(Recipe.items)) + ) + .unique() + .scalars() + .all() + ) + for sub_recipe in item_recipes: + check_recursion(sub_recipe, seen_recipes, db) + + +def get_purchased_product_cost(product: Product, start_date: date, finish_date: date, db: Session) -> Decimal: + query = ( + select(Batch) + .join(Batch.inventories) + .join(Inventory.voucher) + .join(Batch.sku) + .join(StockKeepingUnit.product) + .where(Product.id == product.id, Voucher.date >= start_date, Voucher.date <= finish_date) + ) + batches: List[Batch] = db.execute(query).unique().scalars().all() + skus: List[StockKeepingUnit] = ( + db.execute(select(StockKeepingUnit).where(StockKeepingUnit.product_id == product.id)).scalars().all() + ) + price: List[Decimal] = [] + for batch in batches: + price.append(batch.rate / batch.sku.fraction / batch.sku.product_yield) + else: + for sku in skus: + price.append(sku.cost_price / sku.fraction / sku.product_yield) + return round(sum(price) / len(price), 5) + + +def get_sub_product_cost(product: Product, start_date: date, finish_date: date, db: Session): + recipe: Recipe = ( + db.execute( + select(Recipe) + .join(Recipe.sku) + .where( + StockKeepingUnit.product_id == product.id, + Recipe.valid_from <= start_date, + Recipe.valid_till >= finish_date, + ) + ) + .scalars() + .one_or_none() + ) + + if recipe is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Ingredient {product.name} is not bought and also does not have a recipe for the period.", ) - recipe_cost = 0 - recipe = Recipe( - product_id=product_id, - quantity=old_recipe.quantity, - sale_price=product.sale_price, - valid_from=start_date, - valid_till=finish_date, - ) - for item in old_recipe.recipe_items: - if item.product.is_purchased: - ingredient_cost = get_purchased_product_cost(item.product_id, start_date, finish_date, db) - else: - ingredient_cost = get_sub_product_cost(item.product_id, start_date, finish_date, db) - cost_per_unit = ingredient_cost / (item.product.fraction * item.product.product_yield) - recipe_cost += cost_per_unit * item.quantity - recipe.recipe_items.append(RecipeItem(None, item.product_id, item.quantity, ingredient_cost)) - - recipe.cost_price = round(recipe_cost / old_recipe.quantity, 2) - - save_recipe(recipe, db) - return recipe_cost + return recipe.cost_price -def update_old_rows(product_id, valid_from: date, valid_till: date, effective_date: date, db: Session): +def update_old_rows(product_id, valid_from: date, valid_till: date, db: Session): old = ( db.execute( select(Recipe) @@ -186,7 +284,6 @@ def update_old_rows(product_id, valid_from: date, valid_till: date, effective_da Recipe.product_id == product_id, Recipe.valid_from < valid_till, Recipe.valid_till > valid_from, - or_(Recipe.effective_to == None, Recipe.effective_to >= effective_date), ) .order_by(Recipe.effective_from) ) @@ -205,7 +302,6 @@ def update_old_rows(product_id, valid_from: date, valid_till: date, effective_da sale_price=item.sale_price, valid_from=item.valid_from, valid_till=valid_from - timedelta(days=1), - effective_from=effective_date, ) for ri in item.recipe_items: recipe.recipe_items.append(RecipeItem(None, ri.product_id, ri.quantity, ri.price)) @@ -218,19 +314,11 @@ def update_old_rows(product_id, valid_from: date, valid_till: date, effective_da sale_price=item.sale_price, valid_from=valid_till + timedelta(days=1), valid_till=item.valid_till, - effective_from=effective_date, ) for ri in item.recipe_items: recipe.recipe_items.append(RecipeItem(None, ri.product_id, ri.quantity, ri.price)) new_recipes.append(recipe) - if item.effective_from == effective_date and item.effective_to is None: - for ri in item.recipe_items: - db.delete(ri) - db.delete(item) - else: - item.effective_to = effective_date - for recipe in new_recipes: db.add(recipe) for item in recipe.recipe_items: @@ -241,24 +329,46 @@ def update_old_rows(product_id, valid_from: date, valid_till: date, effective_da @router.delete("/{id_}", response_model=schemas.RecipeBlank) def delete_route( id_: uuid.UUID, + request: Request, user: UserToken = Security(get_user, scopes=["recipes"]), ) -> schemas.RecipeBlank: with SessionFuture() as db: recipe: Recipe = db.execute(select(Recipe).where(Recipe.id == id_)).scalar_one() - if len(recipe.product.recipes) > 1: - db.delete(recipe) - else: - db.delete(recipe) - db.delete(recipe.product) + recipe_ids: List[uuid.UUID] = ( + db.execute( + select(func.distinct(RecipeItem.recipe_id)).where( + RecipeItem.product_id + == select(StockKeepingUnit.product_id) + .where(StockKeepingUnit.id == select(Recipe.sku_id).where(Recipe.id == id_).scalar_subquery()) + .scalar_subquery(), + RecipeItem.recipe_id.in_( + select(Recipe.id).where( + Recipe.valid_from <= recipe.valid_till, Recipe.valid_till >= recipe.valid_from + ) + ), + ) + ) + .scalars() + .all() + ) + if len(recipe_ids) > 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="This recipe has other recipes dependent on it and cannot be deleted", + ) + + db.execute(delete(RecipeItem).where(RecipeItem.recipe_id == id_)) + db.execute(delete(Recipe).where(Recipe.id == id_)) db.commit() - return recipe_blank() + return recipe_blank(request.session) @router.get("", response_model=schemas.RecipeBlank) def show_blank( + request: Request, user: UserToken = Security(get_user, scopes=["recipes"]), ) -> schemas.RecipeBlank: - return recipe_blank() + return recipe_blank(request.session) @router.get("/list", response_model=List[schemas.Recipe]) @@ -267,31 +377,64 @@ async def show_list( ) -> List[schemas.Recipe]: with SessionFuture() as db: list_: List[Recipe] = ( - db.execute( - select(Recipe).join(Recipe.product).order_by(Recipe.product_id).order_by(desc(Recipe.valid_till)) - ) + db.execute(select(Recipe).join(Recipe.sku).order_by(Recipe.sku_id).order_by(desc(Recipe.valid_till))) .scalars() .all() ) return [ schemas.Recipe( id=item.id, - product=schemas.ProductLink(id=item.product.id, name=item.product.name), - quantity=item.quantity, + sku=schemas.ProductLink(id=item.sku.id, name=f"{item.sku.product.name} ({item.sku.units})"), + recipeYield=item.recipe_yield, costPrice=item.cost_price, salePrice=item.sale_price, - isLocked=item.is_locked, - notes="", + notes=str(item.sku.product.product_group_id), validFrom=item.valid_from, validTill=item.valid_till, - effectiveFrom=item.valid_from, - effectiveTill=item.valid_till, items=[], ) for item in list_ ] +@router.get("/ingredient-details/{id_}", response_model=ProductSku) +def show_ingredient_id( + id_: uuid.UUID, + start_date: Optional[date] = Depends(report_start_date), + finish_date: Optional[date] = Depends(report_finish_date), + user: UserToken = Security(get_user, scopes=["recipes"]), +) -> ProductSku: + with SessionFuture() as db: + query = ( + select(Batch) + .join(Batch.inventories) + .join(Inventory.voucher) + .join(Batch.sku) + .join(StockKeepingUnit.product) + .where(Product.id == id_) + ) + if start_date is not None: + query = query.where(Voucher.date >= start_date) + if finish_date is not None: + query = query.where(Voucher.date <= finish_date) + batches: List[Batch] = db.execute(query).unique().scalars().all() + product: Product = db.execute(select(Product).join(Product.skus).where(Product.id == id_)).unique().scalar_one() + price: List[Decimal] = [] + for batch in batches: + price.append(batch.rate / batch.sku.fraction / batch.sku.product_yield) + else: + for sku in product.skus: + price.append(sku.cost_price / sku.fraction / sku.product_yield) + return ProductSku( + id=id_, + name=product.name, + fractionUnits=product.fraction_units, + costPrice=round(sum(price) / len(price), 2), + salePrice=0, + isRateContracted=False, + ) + + @router.get("/{id_}", response_model=schemas.Recipe) def show_id( id_: uuid.UUID, @@ -305,35 +448,36 @@ def show_id( def recipe_info(recipe: Recipe) -> schemas.Recipe: return schemas.Recipe( id=recipe.id, - product=schemas.ProductLink( - id=recipe.product_id, - name=recipe.product.name, + sku=schemas.ProductLink( + id=recipe.sku_id, + name=f"{recipe.sku.product.name} ({recipe.sku.units})", ), - quantity=recipe.quantity, + recipeYield=recipe.recipe_yield, salePrice=recipe.sale_price, costPrice=recipe.cost_price, validFrom=recipe.valid_from, validTill=recipe.valid_till, - isLocked=recipe.is_locked, notes="", items=[ brewman.schemas.recipe_item.RecipeItem( id=item.id, product=schemas.ProductLink( id=item.product.id, - name=item.product.name, + name=f"{item.product.name} ({item.product.fraction_units})", ), - quantity=item.quantity, - price=item.price, + quantity=round(item.quantity, 2), + price=round(item.price, 5), ) for item in recipe.items ], ) -def recipe_blank() -> schemas.RecipeBlank: +def recipe_blank(session: dict) -> schemas.RecipeBlank: return schemas.RecipeBlank( - quantity=0, + validFrom=get_start_date(session), + validTill=get_finish_date(session), + recipeYield=1, costPrice=0, salePrice=0, isLocked=False, diff --git a/brewman/brewman/routers/reports/closing_stock.py b/brewman/brewman/routers/reports/closing_stock.py index 1db6cb72..3f52f783 100644 --- a/brewman/brewman/routers/reports/closing_stock.py +++ b/brewman/brewman/routers/reports/closing_stock.py @@ -200,7 +200,7 @@ def build_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> List[sc def get_opening_stock(date_: date, db: Session) -> Decimal: - opening_stock: Decimal = db.execute( + opening_stock: Optional[Decimal] = db.execute( select(func.sum(Inventory.quantity * Inventory.rate * (1 + Inventory.tax) * Journal.debit)) .join(Journal.voucher) .join(Journal.account) @@ -211,7 +211,7 @@ def get_opening_stock(date_: date, db: Session) -> Decimal: def get_closing_stock(date_, db: Session) -> Decimal: - closing_stock: Decimal = db.execute( + closing_stock: Optional[Decimal] = db.execute( select(func.sum(Inventory.quantity * Inventory.rate * (1 + Inventory.tax) * Journal.debit)) .join(Journal.voucher) .join(Journal.account) diff --git a/brewman/brewman/routers/reports/non_contract_purchase.py b/brewman/brewman/routers/reports/non_contract_purchase.py new file mode 100644 index 00000000..d1075ae5 --- /dev/null +++ b/brewman/brewman/routers/reports/non_contract_purchase.py @@ -0,0 +1,87 @@ +from typing import List + +import brewman.schemas.non_contract_purchase as schemas + +from brewman.core.security import get_current_active_user as get_user +from brewman.db.session import SessionFuture +from brewman.models.batch import Batch +from brewman.models.inventory import Inventory +from brewman.models.journal import Journal +from brewman.models.product import Product +from brewman.models.rate_contract import RateContract +from brewman.models.stock_keeping_unit import StockKeepingUnit +from brewman.models.voucher import Voucher +from brewman.models.voucher_type import VoucherType +from brewman.schemas.user import UserToken +from fastapi import APIRouter, Security +from sqlalchemy import select +from sqlalchemy.orm import Session, contains_eager, joinedload + + +router = APIRouter() + + +@router.post("", response_model=List[schemas.NonContractPurchase]) +def non_contract_purchases( + user: UserToken = Security(get_user, scopes=["product-ledger"]) +) -> List[schemas.NonContractPurchase]: + + with SessionFuture() as db: + return report(db) + + +def report(db: Session) -> List[schemas.NonContractPurchase]: + rcs: List[RateContract] = ( + db.execute( + select(RateContract) + .join(RateContract.vendor) + .join(RateContract.items) + .options( + joinedload(RateContract.items, innerjoin=True), + contains_eager(RateContract.items), + joinedload(RateContract.vendor, innerjoin=True), + contains_eager(RateContract.vendor), + ) + ) + .unique() + .scalars() + .all() + ) + list_: List[schemas.NonContractPurchase] = [] + for rc in rcs: + for item in rc.items: + invs = db.execute( + select(Voucher.id, Voucher.date, Inventory.id, Inventory.rate) + .join(Inventory.batch) + .join(Inventory.voucher) + .join(Voucher.journals) + .where( + Batch.sku_id == item.sku_id, + Voucher.date >= rc.valid_from, + Voucher.date <= rc.valid_till, + Voucher.voucher_type == VoucherType.PURCHASE, + Journal.account_id == rc.vendor_id, + Inventory.rate != item.price, + ) + ).all() + for inv in invs: + p, u = db.execute( + select(Product.name, StockKeepingUnit.units) + .join(Product.skus) + .where(StockKeepingUnit.product_id == item.sku_id) + ).one() + list_.append( + schemas.NonContractPurchase( + date=inv.date, + vendor=rc.vendor.name, + product=f"{p} ({u})", + url=[ + "/", + "purchase", + str(inv.id), + ], + contractPrice=item.price, + purchasePrice=inv.rate, + ) + ) + return list_ diff --git a/brewman/brewman/routers/voucher.py b/brewman/brewman/routers/voucher.py index 580182e7..01894de7 100644 --- a/brewman/brewman/routers/voucher.py +++ b/brewman/brewman/routers/voucher.py @@ -1,13 +1,13 @@ import uuid -from datetime import date, datetime +from datetime import date from decimal import Decimal from typing import List, Optional, Tuple import brewman.schemas.voucher as output from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import and_, delete, distinct, func, or_, select +from sqlalchemy import and_, distinct, func, or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -21,8 +21,6 @@ from ..models.attendance_type import AttendanceType from ..models.cost_centre import CostCentre from ..models.db_image import DbImage from ..models.employee import Employee -from ..models.employee_benefit import EmployeeBenefit -from ..models.incentive import Incentive from ..models.inventory import Inventory from ..models.journal import Journal from ..models.voucher import Voucher @@ -122,6 +120,8 @@ def delete_voucher( destination = j_item.cost_centre_id else: source = j_item.cost_centre_id + + batch_consumed: Optional[bool] if source == CostCentre.cost_centre_purchase(): batch_consumed = True elif destination == CostCentre.cost_centre_purchase(): @@ -259,8 +259,9 @@ def voucher_info(voucher: Voucher, db: Session) -> output.Voucher: json_voucher.incentive = next(x.amount for x in voucher.journals if x.account_id == Account.incentive_id()) for inventory in voucher.inventories: text = ( - f"{inventory.batch.sku.product.name} ({inventory.batch.sku.units}) {inventory.batch.quantity_remaining:.2f}@" - f"{inventory.batch.rate:.2f} from {inventory.batch.name.strftime('%d-%b-%Y')}" + f"{inventory.batch.sku.product.name} ({inventory.batch.sku.units}) " + f"{inventory.batch.quantity_remaining:.2f}@{inventory.batch.rate:.2f} " + f"from {inventory.batch.name.strftime('%d-%b-%Y')}" ) json_voucher.inventories.append( output.Inventory( diff --git a/brewman/brewman/schemas/non_contract_purchase.py b/brewman/brewman/schemas/non_contract_purchase.py new file mode 100644 index 00000000..09b91a6a --- /dev/null +++ b/brewman/brewman/schemas/non_contract_purchase.py @@ -0,0 +1,28 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import List + +from pydantic import validator +from pydantic.main import BaseModel + +from . import to_camel + + +class NonContractPurchase(BaseModel): + date_: date + vendor: str + product: str + url: List[str] + contract_price: Decimal + purchase_price: Decimal + + class Config: + anystr_strip_whitespace = True + alias_generator = to_camel + json_encoders = {date: lambda v: v.strftime("%d-%b-%Y")} + + @validator("date_", pre=True) + def parse_start_date(cls, value): + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() diff --git a/brewman/brewman/schemas/recipe.py b/brewman/brewman/schemas/recipe.py index a9d90555..de81b6d2 100644 --- a/brewman/brewman/schemas/recipe.py +++ b/brewman/brewman/schemas/recipe.py @@ -19,8 +19,8 @@ class RecipeIn(BaseModel): sale_price: Decimal notes: str - valid_from: Optional[date] - valid_till: Optional[date] + valid_from: date + valid_till: date items: List[RecipeItem] @@ -33,16 +33,12 @@ class RecipeIn(BaseModel): def parse_valid_from(cls, value): if isinstance(value, date): return value - if value is None: - return value return datetime.strptime(value, "%d-%b-%Y").date() @validator("valid_till", pre=True) def parse_valid_till(cls, value): if isinstance(value, date): return value - if value is None: - return value return datetime.strptime(value, "%d-%b-%Y").date() diff --git a/overlord/.eslintrc.json b/overlord/.eslintrc.json index 3144b51f..2f71e5ac 100644 --- a/overlord/.eslintrc.json +++ b/overlord/.eslintrc.json @@ -10,8 +10,7 @@ ], "parserOptions": { "project": [ - "tsconfig.json", - "e2e/tsconfig.json" + "tsconfig.json" ], "createDefaultProgram": true }, diff --git a/overlord/angular.json b/overlord/angular.json index 68219de0..e6148924 100644 --- a/overlord/angular.json +++ b/overlord/angular.json @@ -52,13 +52,7 @@ "with": "src/environments/environment.prod.ts" } ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "namedChunks": false, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true + "outputHashing": "all" }, "development": { "buildOptimizer": false, @@ -82,7 +76,6 @@ } }, "options": { - "browserTarget": "overlord:build", "proxyConfig": "proxy.conf.json" }, "defaultConfiguration": "development" diff --git a/overlord/package.json b/overlord/package.json index f8cb151a..7258f81e 100644 --- a/overlord/package.json +++ b/overlord/package.json @@ -39,21 +39,20 @@ }, "devDependencies": { "@angular-devkit/build-angular": "^12.2.13", - "@angular/cli": "^12.2.13", - "@angular/compiler-cli": "^12.2.13", - "@angular/language-service": "^12.2.13", + "@angular-eslint/builder": "^12.6.1", "@angular-eslint/eslint-plugin": "^12.6.1", "@angular-eslint/eslint-plugin-template": "^12.6.1", "@angular-eslint/schematics": "^12.6.1", "@angular-eslint/template-parser": "^12.6.1", + "@angular/cli": "^12.2.13", + "@angular/compiler-cli": "^12.2.13", + "@angular/language-service": "^12.2.13", "@types/jasmine": "~3.7.4", "@types/node": "^16.11.6", - "@typescript-eslint/eslint-plugin": "5.3.0", - "@typescript-eslint/parser": "5.3.0", - "eslint": "^8.2.0", + "@typescript-eslint/eslint-plugin": "4.28.2", + "@typescript-eslint/parser": "4.28.2", + "eslint": "^7.26.0", "eslint-plugin-import": "^2.25.2", - "eslint-plugin-jsdoc": "^37.0.3", - "eslint-plugin-prefer-arrow": "1.2.3", "husky": "^7.0.4", "jasmine-core": "~3.8.0", "jasmine-spec-reporter": "7.0.0", diff --git a/overlord/src/app/app-routing.module.ts b/overlord/src/app/app-routing.module.ts index 42960976..78f94462 100644 --- a/overlord/src/app/app-routing.module.ts +++ b/overlord/src/app/app-routing.module.ts @@ -80,6 +80,10 @@ const appRoutes: Routes = [ loadChildren: () => import('./rate-contract/rate-contract.module').then((mod) => mod.RateContractModule), }, + { + path: 'recipes', + loadChildren: () => import('./recipe/recipe.module').then((mod) => mod.RecipeModule), + }, { path: 'roles', loadChildren: () => import('./role/role.module').then((mod) => mod.RoleModule), @@ -105,6 +109,13 @@ const appRoutes: Routes = [ loadChildren: () => import('./net-transactions/net-transactions.module').then((mod) => mod.NetTransactionsModule), }, + { + path: 'non-contract-purchase', + loadChildren: () => + import('./non-contact-purchase/non-contract-purchase.module').then( + (mod) => mod.NonContractPurchaseModule, + ), + }, { path: 'payment', loadChildren: () => import('./payment/payment.module').then((mod) => mod.PaymentModule), diff --git a/overlord/src/app/batch-integrity-report/batch-integrity-report.service.ts b/overlord/src/app/batch-integrity-report/batch-integrity-report.service.ts index 787406aa..a4b96621 100644 --- a/overlord/src/app/batch-integrity-report/batch-integrity-report.service.ts +++ b/overlord/src/app/batch-integrity-report/batch-integrity-report.service.ts @@ -7,7 +7,6 @@ import { ErrorLoggerService } from '../core/error-logger.service'; import { BatchIntegrity } from './batch-integrity'; -const url = '/api/batchIntegrity'; const serviceName = 'BatchIntegrityReportService'; @Injectable({ diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html index 69404312..a11769d0 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -34,6 +34,7 @@ Stock Movement Rate Contracts Batch Integrity + Non Contract Purchases Product Reports @@ -52,6 +53,7 @@ Cost Centres Products Product Groups + Recipes Masters diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase-datasource.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase-datasource.ts new file mode 100644 index 00000000..93dd01b2 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable, of as observableOf } from 'rxjs'; + +import { NonContractPurchase } from './non-contract-purchase'; + +export class NonContractPurchaseDatasource extends DataSource { + constructor(public data: NonContractPurchase[]) { + super(); + } + + connect(): Observable { + return observableOf(this.data); + } + + disconnect() {} +} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase-resolver.service.spec.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase-resolver.service.spec.ts new file mode 100644 index 00000000..2244be82 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase-resolver.service.spec.ts @@ -0,0 +1,20 @@ +import { HttpClientModule } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; + +import { NonContractPurchaseResolverService } from './non-contract-purchase-resolver.service'; + +describe('NonContractPurchaseResolverService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule], + providers: [NonContractPurchaseResolverService], + }); + }); + + it('should be created', inject( + [NonContractPurchaseResolverService], + (service: NonContractPurchaseResolverService) => { + expect(service).toBeTruthy(); + }, + )); +}); diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase-resolver.service.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase-resolver.service.ts new file mode 100644 index 00000000..208cddf0 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase-resolver.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; + +import { NonContractPurchase } from './non-contract-purchase'; +import { NonContractPurchaseService } from './non-contract-purchase.service'; + +@Injectable({ + providedIn: 'root', +}) +export class NonContractPurchaseResolverService implements Resolve { + constructor(private ser: NonContractPurchaseService) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + return this.ser.nonContractPurchase(); + } +} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase-routing.module.spec.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase-routing.module.spec.ts new file mode 100644 index 00000000..c065a9df --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase-routing.module.spec.ts @@ -0,0 +1,13 @@ +import { NonContractPurchaseRoutingModule } from './non-contract-purchase-routing.module'; + +describe('NonContractPurchaseRoutingModule', () => { + let nonContractPurchaseRoutingModule: NonContractPurchaseRoutingModule; + + beforeEach(() => { + nonContractPurchaseRoutingModule = new NonContractPurchaseRoutingModule(); + }); + + it('should create an instance', () => { + expect(nonContractPurchaseRoutingModule).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase-routing.module.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase-routing.module.ts new file mode 100644 index 00000000..ca47e838 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase-routing.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { AuthGuard } from '../auth/auth-guard.service'; + +import { NonContractPurchaseResolverService } from './non-contract-purchase-resolver.service'; +import { NonContractPurchaseComponent } from './non-contract-purchase.component'; + +const nonContractPurchaseRoutes: Routes = [ + { + path: '', + component: NonContractPurchaseComponent, + canActivate: [AuthGuard], + data: { + permission: 'Product Ledger', + }, + resolve: { + info: NonContractPurchaseResolverService, + }, + runGuardsAndResolvers: 'always', + }, +]; + +@NgModule({ + imports: [CommonModule, RouterModule.forChild(nonContractPurchaseRoutes)], + exports: [RouterModule], + providers: [NonContractPurchaseResolverService], +}) +export class NonContractPurchaseRoutingModule {} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.component.css b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.css new file mode 100644 index 00000000..db9f0ff9 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.css @@ -0,0 +1,19 @@ +.right { + display: flex; + justify-content: flex-end; +} +.my-margin { + margin: 0 12px; +} +.selected { + background: #fff3cd; +} + +.unposted { + background: #f8d7da; +} + +.mat-column-date, +.mat-column-voucherType { + max-width: 100px; +} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.component.html b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.html new file mode 100644 index 00000000..2799cedd --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.html @@ -0,0 +1,47 @@ + + + Non Contract Purchases + + + + + + Date + {{ row.date }} + + + + + Vendor + {{ row.vendor }} + + + + + Product + {{ row.product }} + + + + + Contract + {{ + row.contractPrice | currency: 'INR' + }} + + + + + Purchase + {{ + row.purchasePrice | currency: 'INR' + }} + + + + + + + diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.component.spec.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.spec.ts new file mode 100644 index 00000000..6a793c83 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.spec.ts @@ -0,0 +1,29 @@ +import { HttpClientModule } from '@angular/common/http'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NonContractPurchaseComponent } from './non-contract-purchase.component'; + +describe('NonContractPurchaseComponent', () => { + let component: NonContractPurchaseComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule, RouterTestingModule], + declarations: [NonContractPurchaseComponent], + }).compileComponents(); + }), + ); + + beforeEach(() => { + fixture = TestBed.createComponent(NonContractPurchaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.component.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.ts new file mode 100644 index 00000000..e1577ba6 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.component.ts @@ -0,0 +1,30 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { NonContractPurchase } from './non-contract-purchase'; +import { NonContractPurchaseDatasource } from './non-contract-purchase-datasource'; + +@Component({ + selector: 'app-non-contract-purchase', + templateUrl: './non-contract-purchase.component.html', + styleUrls: ['./non-contract-purchase.component.css'], +}) +export class NonContractPurchaseComponent implements OnInit { + info: NonContractPurchase[] = []; + dataSource: NonContractPurchaseDatasource = new NonContractPurchaseDatasource(this.info); + displayedColumns = ['date', 'vendor', 'product', 'contractPrice', 'purchasePrice']; + + constructor(private route: ActivatedRoute, private router: Router) {} + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { info: NonContractPurchase[] }; + this.info = data.info; + this.dataSource = new NonContractPurchaseDatasource(this.info); + }); + } + + refresh() { + this.router.navigate([]); + } +} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.module.spec.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.module.spec.ts new file mode 100644 index 00000000..0b26fa97 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.module.spec.ts @@ -0,0 +1,13 @@ +import { NonContractPurchaseModule } from './non-contract-purchase.module'; + +describe('NonContractPurchaseModule', () => { + let nonContractPurchaseModule: NonContractPurchaseModule; + + beforeEach(() => { + nonContractPurchaseModule = new NonContractPurchaseModule(); + }); + + it('should create an instance', () => { + expect(nonContractPurchaseModule).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.module.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.module.ts new file mode 100644 index 00000000..fead1839 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.module.ts @@ -0,0 +1,50 @@ +import { A11yModule } from '@angular/cdk/a11y'; +import { CdkTableModule } from '@angular/cdk/table'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MomentDateAdapter } from '@angular/material-moment-adapter'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; + +import { SharedModule } from '../shared/shared.module'; + +import { NonContractPurchaseRoutingModule } from './non-contract-purchase-routing.module'; +import { NonContractPurchaseComponent } from './non-contract-purchase.component'; + +@NgModule({ + imports: [ + A11yModule, + CommonModule, + CdkTableModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatRadioModule, + MatSortModule, + MatTableModule, + SharedModule, + NonContractPurchaseRoutingModule, + MatExpansionModule, + MatDatepickerModule, + ReactiveFormsModule, + FlexLayoutModule, + ], + declarations: [NonContractPurchaseComponent], +}) +export class NonContractPurchaseModule {} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.service.spec.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.service.spec.ts new file mode 100644 index 00000000..c68562c3 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.service.spec.ts @@ -0,0 +1,20 @@ +import { HttpClientModule } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; + +import { NonContractPurchaseService } from './non-contract-purchase.service'; + +describe('NonContractPurchaseService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule], + providers: [NonContractPurchaseService], + }); + }); + + it('should be created', inject( + [NonContractPurchaseService], + (service: NonContractPurchaseService) => { + expect(service).toBeTruthy(); + }, + )); +}); diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.service.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.service.ts new file mode 100644 index 00000000..56437c32 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.service.ts @@ -0,0 +1,26 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; + +import { NonContractPurchase } from './non-contract-purchase'; + +const url = '/api/non-contract-purchase'; +const serviceName = 'NonContractPurchaseService'; + +@Injectable({ + providedIn: 'root', +}) +export class NonContractPurchaseService { + constructor(private http: HttpClient, private log: ErrorLoggerService) {} + + nonContractPurchase(): Observable { + return this.http + .post(url, {}) + .pipe(catchError(this.log.handleError(serviceName, 'nonContractPurchase'))) as Observable< + NonContractPurchase[] + >; + } +} diff --git a/overlord/src/app/non-contact-purchase/non-contract-purchase.ts b/overlord/src/app/non-contact-purchase/non-contract-purchase.ts new file mode 100644 index 00000000..e680cb38 --- /dev/null +++ b/overlord/src/app/non-contact-purchase/non-contract-purchase.ts @@ -0,0 +1,18 @@ +export class NonContractPurchase { + date: string; + vendor: string; + product: string; + url: string[]; + contractPrice: number; + purchasePrice: number; + + public constructor(init?: Partial) { + this.date = ''; + this.vendor = ''; + this.product = ''; + this.url = []; + this.contractPrice = 0; + this.purchasePrice = 0; + Object.assign(this, init); + } +} diff --git a/overlord/src/app/product/product-list/product-list-datasource.ts b/overlord/src/app/product/product-list/product-list-datasource.ts index c9d5eed1..b3efa68e 100644 --- a/overlord/src/app/product/product-list/product-list-datasource.ts +++ b/overlord/src/app/product/product-list/product-list-datasource.ts @@ -39,16 +39,15 @@ export class ProductListDataSource extends DataSource { dataMutations.push((this.sort as MatSort).sortChange); } - return merge(...dataMutations) - .pipe( - map(() => this.getFilteredData([...this.data])), - tap((x: Product[]) => { - if (this.paginator) { - this.paginator.length = x.length; - } - }), - ) - .pipe(map((x: Product[]) => this.getPagedData(this.getSortedData(x)))); + return merge(...dataMutations).pipe( + map(() => this.getFilteredData([...this.data])), + tap((x: Product[]) => { + if (this.paginator) { + this.paginator.length = x.length; + } + }), + map((x: Product[]) => this.getPagedData(this.getSortedData(x))), + ); } disconnect() {} diff --git a/overlord/src/app/recipe/recipe-detail/recipe-detail-datasource.ts b/overlord/src/app/recipe/recipe-detail/recipe-detail-datasource.ts new file mode 100644 index 00000000..4b12aaad --- /dev/null +++ b/overlord/src/app/recipe/recipe-detail/recipe-detail-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +import { RecipeItem } from '../recipe-item'; + +export class RecipeDetailDatasource extends DataSource { + constructor(private data: Observable) { + super(); + } + + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/overlord/src/app/recipe/recipe-detail/recipe-detail.component.css b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.css new file mode 100644 index 00000000..82c7afd6 --- /dev/null +++ b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.css @@ -0,0 +1,3 @@ +.example-card { + max-width: 400px; +} diff --git a/overlord/src/app/recipe/recipe-detail/recipe-detail.component.html b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.html new file mode 100644 index 00000000..b8871d6a --- /dev/null +++ b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.html @@ -0,0 +1,206 @@ + + + Recipe Detail + + + + + + + + + + + + + + + + + + + + {{ + product.name + }} + + + + Yield + + + + Sale Price + ₹ + + + + Cost Price + ₹ + + + + Cost Percentage + + % + + + + + + + {{ product.name }} ({{ product.fractionUnits }}) + + + + Quantity + + + + Rate + ₹ + + + + Add + + + + + Product + {{ row.product.name }} + + + + + Quantity + {{ row.quantity | number: '1.2-2' }} {{ row.product.fractionUnits }} + + + + + Rate + {{ row.price | currency: 'INR' }} + + + + + Amount + {{ + row.quantity * row.price | currency: 'INR' + }} + + + + + Action + + + delete + + + + + + + + + + + + {{ item.id ? 'Update' : 'Save' }} + + Delete + + diff --git a/overlord/src/app/recipe/recipe-detail/recipe-detail.component.spec.ts b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.spec.ts new file mode 100644 index 00000000..09e48d75 --- /dev/null +++ b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.spec.ts @@ -0,0 +1,29 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { RecipeDetailComponent } from './recipe-detail.component'; + +describe('ProductGroupDetailComponent', () => { + let component: RecipeDetailComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, RouterTestingModule], + declarations: [RecipeDetailComponent], + }).compileComponents(); + }), + ); + + beforeEach(() => { + fixture = TestBed.createComponent(RecipeDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/recipe/recipe-detail/recipe-detail.component.ts b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.ts new file mode 100644 index 00000000..9c7a0eec --- /dev/null +++ b/overlord/src/app/recipe/recipe-detail/recipe-detail.component.ts @@ -0,0 +1,258 @@ +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { round } from 'mathjs'; +import * as moment from 'moment'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; + +import { Product } from '../../core/product'; +import { ProductSku } from '../../core/product-sku'; +import { ToasterService } from '../../core/toaster.service'; +import { ProductService } from '../../product/product.service'; +import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; +import { MathService } from '../../shared/math.service'; +import { Recipe } from '../recipe'; +import { RecipeItem } from '../recipe-item'; +import { RecipeService } from '../recipe.service'; + +import { RecipeDetailDatasource } from './recipe-detail-datasource'; + +@Component({ + selector: 'app-product-group-detail', + templateUrl: './recipe-detail.component.html', + styleUrls: ['./recipe-detail.component.css'], +}) +export class RecipeDetailComponent implements OnInit, AfterViewInit { + @ViewChild('productElement', { static: true }) productElement?: ElementRef; + @ViewChild('ingredientElement', { static: true }) ingredientElement?: ElementRef; + public itemsObservable = new BehaviorSubject([]); + dataSource: RecipeDetailDatasource = new RecipeDetailDatasource(this.itemsObservable); + form: FormGroup; + product: ProductSku | null; + products: Observable; + ingredient: ProductSku | null; + ingredients: Observable; + item: Recipe = new Recipe(); + + displayedColumns = ['product', 'quantity', 'rate', 'amount', 'action']; + + constructor( + private route: ActivatedRoute, + private router: Router, + private fb: FormBuilder, + private dialog: MatDialog, + private toaster: ToasterService, + private math: MathService, + private ser: RecipeService, + private productSer: ProductService, + ) { + this.product = null; + this.ingredient = null; + this.form = this.fb.group({ + validFrom: '', + validTill: '', + recipeYield: '', + salePrice: '', + costPrice: '', + costPercentage: '', + product: new Product(), + addRow: this.fb.group({ + ingredient: '', + quantity: '', + rate: '', + }), + }); + // Setup Product Autocomplete + this.products = (this.form.get('product') as FormControl).valueChanges.pipe( + startWith(null), + map((x) => (x !== null && x.length >= 1 ? x : null)), + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocompleteSku(x, false))), + ); + // Setup Product Autocomplete + this.ingredients = ( + (this.form.get('addRow') as FormControl).get('ingredient') as FormControl + ).valueChanges.pipe( + startWith(null), + map((x) => (x !== null && x.length >= 1 ? x : null)), + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => + x === null ? observableOf([]) : this.productSer.autocompleteProduct(x, null), + ), + ); + } + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { item: Recipe }; + this.showItem(data.item); + this.updateView(); + }); + } + + showItem(item: Recipe) { + this.item = item; + this.form.setValue({ + validFrom: moment(item.validFrom, 'DD-MMM-YYYY').toDate(), + validTill: moment(item.validTill, 'DD-MMM-YYYY').toDate(), + recipeYield: '' + item.recipeYield, + salePrice: '' + item.salePrice, + costPrice: '' + item.costPrice, + costPercentage: '', + product: item.sku, + addRow: { + ingredient: null, + quantity: '', + rate: '', + }, + }); + this.dataSource = new RecipeDetailDatasource(this.itemsObservable); + } + + ngAfterViewInit() { + setTimeout(() => { + if (this.productElement) { + this.productElement.nativeElement.focus(); + } + }, 0); + } + + displayIngredient(product?: Product): string { + return product ? `${product.name} (${product.fractionUnits})` : ''; + } + + displayProduct(product?: Product): string { + return product?.name || ''; + } + + productSelected(event: MatAutocompleteSelectedEvent): void { + const product: ProductSku = event.option.value; + this.item.sku = product; + (this.form.get('product') as FormControl).setValue(product); + (this.form.get('salePrice') as FormControl).setValue('' + (product.salePrice ?? 0)); + } + + ingredientSelected(event: MatAutocompleteSelectedEvent): void { + const ingredient: ProductSku = event.option.value; + this.ingredient = ingredient; + const item = this.getItem(); + this.ser + .getIngredientDetails(ingredient.id, item.validFrom, item.validTill) + .subscribe((x) => + ((this.form.get('addRow') as FormControl).get('rate') as FormControl).setValue( + '' + x.costPrice, + ), + ); + } + + addRow() { + const formValue = (this.form.get('addRow') as FormControl).value; + const quantity = this.math.parseAmount(formValue.quantity, 2); + const rate = this.math.parseAmount(formValue.rate, 2); + if (this.ingredient === null || quantity <= 0 || rate <= 0) { + return; + } + const oldFiltered = this.item.items.filter( + (x) => x.product.id === (this.ingredient as ProductSku).id, + ); + if (oldFiltered.length) { + this.toaster.show('Danger', 'Product already added'); + return; + } + this.item.items.push( + new RecipeItem({ + product: this.ingredient, + quantity, + price: rate, + }), + ); + this.resetAddRow(); + this.updateView(); + } + + resetAddRow() { + (this.form.get('addRow') as FormControl).reset({ + ingredient: null, + quantity: '', + rate: '', + }); + this.ingredient = null; + setTimeout(() => { + if (this.ingredientElement) { + this.ingredientElement.nativeElement.focus(); + } + }, 0); + } + + updateView() { + this.itemsObservable.next(this.item.items); + const costPrice = round( + this.item.items.map((x) => x.quantity * x.price).reduce((p, c) => p + c, 0), + 2, + ); + (this.form.get('costPrice') as FormControl).setValue(costPrice); + const salePrice = this.math.parseAmount(this.form.value.salePrice, 2); + if (salePrice < 0) { + return; + } + const costPercentage = round((100 * costPrice) / salePrice, 2); + (this.form.get('costPercentage') as FormControl).setValue(costPercentage); + } + + deleteRow(row: RecipeItem) { + this.item.items.splice(this.item.items.indexOf(row), 1); + this.updateView(); + } + + save() { + this.ser.saveOrUpdate(this.getItem()).subscribe( + () => { + this.toaster.show('Success', ''); + this.router.navigateByUrl('/recipes'); + }, + (error) => { + this.toaster.show('Danger', error); + }, + ); + } + + delete() { + this.ser.delete(this.item.id as string).subscribe( + () => { + this.toaster.show('Success', ''); + this.router.navigate(['/recipes']); + }, + (error) => { + this.toaster.show('Danger', error); + }, + ); + } + + confirmDelete(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '250px', + data: { title: 'Delete Recipe?', content: 'Are you sure? This cannot be undone.' }, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.delete(); + } + }); + } + + getItem(): Recipe { + const formModel = this.form.value; + this.item.validFrom = moment(formModel.validFrom).format('DD-MMM-YYYY'); + this.item.validTill = moment(formModel.validTill).format('DD-MMM-YYYY'); + this.item.recipeYield = this.math.parseAmount(formModel.recipeYield, 2); + this.item.salePrice = this.math.parseAmount(formModel.salePrice, 2); + this.item.costPrice = this.math.parseAmount(formModel.costPrice, 2); + return this.item; + } +} diff --git a/overlord/src/app/recipe/recipe-item.ts b/overlord/src/app/recipe/recipe-item.ts new file mode 100644 index 00000000..c62c3bf9 --- /dev/null +++ b/overlord/src/app/recipe/recipe-item.ts @@ -0,0 +1,15 @@ +import { ProductSku } from '../core/product-sku'; + +export class RecipeItem { + id: string | undefined; + product: ProductSku; + quantity: number; + price: number; + + public constructor(init?: Partial) { + this.product = new ProductSku(); + this.quantity = 0; + this.price = 0; + Object.assign(this, init); + } +} diff --git a/overlord/src/app/recipe/recipe-list-resolver.service.spec.ts b/overlord/src/app/recipe/recipe-list-resolver.service.spec.ts new file mode 100644 index 00000000..2d28204b --- /dev/null +++ b/overlord/src/app/recipe/recipe-list-resolver.service.spec.ts @@ -0,0 +1,18 @@ +import { HttpClientModule } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { RecipeListResolver } from './recipe-list-resolver.service'; + +describe('RecipeListResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule, RouterTestingModule], + providers: [RecipeListResolver], + }); + }); + + it('should be created', inject([RecipeListResolver], (service: RecipeListResolver) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/overlord/src/app/recipe/recipe-list-resolver.service.ts b/overlord/src/app/recipe/recipe-list-resolver.service.ts new file mode 100644 index 00000000..67ad3d36 --- /dev/null +++ b/overlord/src/app/recipe/recipe-list-resolver.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { Resolve } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; + +import { Recipe } from './recipe'; +import { RecipeService } from './recipe.service'; + +@Injectable({ + providedIn: 'root', +}) +export class RecipeListResolver implements Resolve { + constructor(private ser: RecipeService) {} + + resolve(): Observable { + return this.ser.list(); + } +} diff --git a/overlord/src/app/recipe/recipe-list/recipe-list-datasource.ts b/overlord/src/app/recipe/recipe-list/recipe-list-datasource.ts new file mode 100644 index 00000000..1408d5b5 --- /dev/null +++ b/overlord/src/app/recipe/recipe-list/recipe-list-datasource.ts @@ -0,0 +1,147 @@ +import { DataSource } from '@angular/cdk/collections'; +import { EventEmitter } from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatSort, Sort } from '@angular/material/sort'; +import * as moment from 'moment'; +import { merge, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { Recipe } from '../recipe'; + +export class RecipeListDatasource extends DataSource { + public data: Recipe[]; + public filteredData: Recipe[]; + public validFrom: Date | null; + public validTill: Date | null; + public productGroup: string; + + constructor( + private readonly validFromFilter: Observable, + private readonly validTillFilter: Observable, + private readonly productGroupFilter: Observable, + private readonly dataObs: Observable, + + private paginator?: MatPaginator, + private sort?: MatSort, + ) { + super(); + this.data = []; + this.filteredData = []; + this.validFrom = null; + this.validTill = null; + this.productGroup = ''; + } + + connect(): Observable { + const dataMutations: ( + | Observable + | Observable + | Observable + | EventEmitter + | EventEmitter + )[] = [ + this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + ), + this.validFromFilter.pipe( + tap((x) => { + this.validFrom = x; + }), + ), + this.validTillFilter.pipe( + tap((x) => { + this.validTill = x; + }), + ), + this.productGroupFilter.pipe( + tap((x) => { + this.productGroup = x; + }), + ), + ]; + if (this.paginator) { + dataMutations.push((this.paginator as MatPaginator).page); + } + if (this.sort) { + dataMutations.push((this.sort as MatSort).sortChange); + } + + return merge(...dataMutations).pipe( + map(() => this.getFilteredData(this.data, this.productGroup, this.validFrom, this.validTill)), + tap((x: Recipe[]) => { + if (this.paginator) { + this.paginator.length = x.length; + } + }), + tap((x) => { + this.filteredData = x; + }), + map(() => this.getPagedData(this.getSortedData([...this.filteredData]))), + ); + } + + disconnect() {} + + private getFilteredData( + data: Recipe[], + productGroup: string, + validFrom: Date | null, + validTill: Date | null, + ): Recipe[] { + return data + .filter((x: Recipe) => productGroup === '' || x.notes === productGroup) + .filter((x) => validFrom === null || validFrom <= moment(x.validFrom, 'DD-MMM-YYYY').toDate()) + .filter( + (x) => validTill === null || validTill >= moment(x.validTill, 'DD-MMM-YYYY').toDate(), + ); + } + + private getPagedData(data: Recipe[]) { + if (this.paginator === undefined) { + return data; + } + const startIndex = this.paginator.pageIndex * this.paginator.pageSize; + return data.splice(startIndex, this.paginator.pageSize); + } + + private getSortedData(data: Recipe[]) { + if (this.sort === undefined) { + return data; + } + if (!this.sort.active || this.sort.direction === '') { + return data; + } + + const sort = this.sort as MatSort; + return data.sort((a, b) => { + const isAsc = sort.direction === 'asc'; + switch (sort.active) { + case 'name': + return compare(a.sku.name, b.sku.name, isAsc); + case 'validity': + if (isAsc) { + return compareDate(a.validFrom, b.validFrom, isAsc); + } else { + return compareDate(a.validTill, b.validTill, isAsc); + } + case 'salePrice': + return compare(a.salePrice, b.salePrice, isAsc); + case 'costPrice': + return compare(a.costPrice, b.costPrice, isAsc); + case 'costPercentage': + return compare(a.costPrice / a.salePrice, b.costPrice / b.salePrice, isAsc); + default: + return 0; + } + }); + } +} +/** Simple sort comparator for example ID/Name columns (for client-side sorting). */ +const compare = (a: string | number | Date, b: string | number | Date, isAsc: boolean) => + (a < b ? -1 : 1) * (isAsc ? 1 : -1); +/** Simple sort comparator for example ID/Name columns (for client-side sorting). */ +const compareDate = (a: string, b: string, isAsc: boolean) => + (moment(a, 'DD-MMM-YYYY').toDate() < moment(b, 'DD-MMM-YYYY').toDate() ? -1 : 1) * + (isAsc ? 1 : -1); diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.css b/overlord/src/app/recipe/recipe-list/recipe-list.component.css new file mode 100644 index 00000000..e69de29b diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.html b/overlord/src/app/recipe/recipe-list/recipe-list.component.html new file mode 100644 index 00000000..cd3a88e1 --- /dev/null +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.html @@ -0,0 +1,105 @@ + + + Recipes + + add_box + Add + + + + + + + + + + + + + + + + + Product Type + + -- All Products -- + + {{ mc.name }} + + + + + + + + + Name + {{ row.sku.name }} + + + + + Validity + {{ row.validFrom }} - {{ row.validTill }} + + + + + Sale Price + {{ row.salePrice | currency: 'INR' }} + + + + + Cost Price + {{ row.costPrice | currency: 'INR' }} + + + + + Cost Price + {{ + row.costPrice / row.salePrice | percent: '1.2-2' + }} + + + + + + + + + + diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.spec.ts b/overlord/src/app/recipe/recipe-list/recipe-list.component.spec.ts new file mode 100644 index 00000000..f0099ede --- /dev/null +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { RecipeListComponent } from './recipe-list.component'; + +describe('RoleListComponent', () => { + let component: RecipeListComponent; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + declarations: [RecipeListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipeListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.ts b/overlord/src/app/recipe/recipe-list/recipe-list.component.ts new file mode 100644 index 00000000..0704c7c2 --- /dev/null +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.ts @@ -0,0 +1,94 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { ActivatedRoute } from '@angular/router'; +import * as moment from 'moment'; +import { BehaviorSubject } from 'rxjs'; + +import { ProductGroup } from '../../core/product-group'; +import { Recipe } from '../recipe'; + +import { RecipeListDatasource } from './recipe-list-datasource'; + +@Component({ + selector: 'app-role-list', + templateUrl: './recipe-list.component.html', + styleUrls: ['./recipe-list.component.css'], +}) +export class RecipeListComponent implements OnInit { + @ViewChild(MatPaginator, { static: true }) paginator?: MatPaginator; + @ViewChild(MatSort, { static: true }) sort?: MatSort; + form: FormGroup; + productGroups: ProductGroup[] = []; + validFromFilter: BehaviorSubject = new BehaviorSubject(null); + validTillFilter: BehaviorSubject = new BehaviorSubject(null); + productGroupFilter: BehaviorSubject = new BehaviorSubject(''); + list: Recipe[] = []; + data: BehaviorSubject = new BehaviorSubject([]); + dataSource: RecipeListDatasource = new RecipeListDatasource( + this.validFromFilter, + this.validTillFilter, + this.productGroupFilter, + this.data, + ); + + /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ + displayedColumns = ['name', 'validity', 'salePrice', 'costPrice', 'costPercentage']; + + constructor(private route: ActivatedRoute, private fb: FormBuilder) { + this.form = this.fb.group({ + validFrom: '', + validTill: '', + productGroup: '', + }); + } + + ngOnInit() { + this.dataSource = new RecipeListDatasource( + this.validFromFilter, + this.validTillFilter, + this.productGroupFilter, + this.data, + this.paginator, + this.sort, + ); + // this.dataSource = new RecipeListDatasource(this.validFromFilter, this.validTillFilter, this.productGroupFilter, this.data); + this.route.data.subscribe((value) => { + const data = value as { list: Recipe[]; productGroups: ProductGroup[] }; + const vf = data.list + .map((x) => x.validFrom) + .reduce((p, c) => { + const pdate = moment(p, 'DD-MMM-YYYY').toDate(); + const cdate = moment(c, 'DD-MMM-YYYY').toDate(); + return pdate < cdate ? p : c; + }); + const vt = data.list + .map((x) => x.validTill) + .reduce((p, c) => { + const pdate = moment(p, 'DD-MMM-YYYY').toDate(); + const cdate = moment(c, 'DD-MMM-YYYY').toDate(); + return pdate > cdate ? p : c; + }); + this.productGroups = data.productGroups; + this.form.setValue({ + validFrom: vf === null ? '' : moment(vf, 'DD-MMM-YYYY').toDate(), + validTill: vt === null ? '' : moment(vt, 'DD-MMM-YYYY').toDate(), + productGroup: '', + }); + this.data.next(data.list); + }); + } + + filterProductGroup(val: string) { + this.productGroupFilter.next(val || ''); + } + + filterValidFrom(val: Date) { + this.validFromFilter.next(val); + } + + filterValidTill(val: Date) { + this.validTillFilter.next(val); + } +} diff --git a/overlord/src/app/recipe/recipe-resolver.service.spec.ts b/overlord/src/app/recipe/recipe-resolver.service.spec.ts new file mode 100644 index 00000000..2df94428 --- /dev/null +++ b/overlord/src/app/recipe/recipe-resolver.service.spec.ts @@ -0,0 +1,18 @@ +import { HttpClientModule } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { RecipeResolver } from './recipe-resolver.service'; + +describe('RecipeResolver', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule, RouterTestingModule], + providers: [RecipeResolver], + }); + }); + + it('should be created', inject([RecipeResolver], (service: RecipeResolver) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/overlord/src/app/recipe/recipe-resolver.service.ts b/overlord/src/app/recipe/recipe-resolver.service.ts new file mode 100644 index 00000000..181b8fb1 --- /dev/null +++ b/overlord/src/app/recipe/recipe-resolver.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; + +import { Recipe } from './recipe'; +import { RecipeService } from './recipe.service'; + +@Injectable({ + providedIn: 'root', +}) +export class RecipeResolver implements Resolve { + constructor(private ser: RecipeService) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + const id = route.paramMap.get('id'); + return this.ser.get(id); + } +} diff --git a/overlord/src/app/recipe/recipe-routing.module.spec.ts b/overlord/src/app/recipe/recipe-routing.module.spec.ts new file mode 100644 index 00000000..091eac3e --- /dev/null +++ b/overlord/src/app/recipe/recipe-routing.module.spec.ts @@ -0,0 +1,13 @@ +import { RecipeRoutingModule } from './recipe-routing.module'; + +describe('RecipeRoutingModule', () => { + let recipeRoutingModule: RecipeRoutingModule; + + beforeEach(() => { + recipeRoutingModule = new RecipeRoutingModule(); + }); + + it('should create an instance', () => { + expect(recipeRoutingModule).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/recipe/recipe-routing.module.ts b/overlord/src/app/recipe/recipe-routing.module.ts new file mode 100644 index 00000000..91d64416 --- /dev/null +++ b/overlord/src/app/recipe/recipe-routing.module.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { AuthGuard } from '../auth/auth-guard.service'; +import { ProductGroupListResolver } from '../product-group/product-group-list-resolver.service'; + +import { RecipeDetailComponent } from './recipe-detail/recipe-detail.component'; +import { RecipeListResolver } from './recipe-list-resolver.service'; +import { RecipeListComponent } from './recipe-list/recipe-list.component'; +import { RecipeResolver } from './recipe-resolver.service'; + +const recipeRoutes: Routes = [ + { + path: '', + component: RecipeListComponent, + canActivate: [AuthGuard], + data: { + permission: 'Recipes', + }, + resolve: { + list: RecipeListResolver, + productGroups: ProductGroupListResolver, + }, + }, + { + path: 'new', + component: RecipeDetailComponent, + canActivate: [AuthGuard], + data: { + permission: 'Recipes', + }, + resolve: { + item: RecipeResolver, + }, + }, + { + path: ':id', + component: RecipeDetailComponent, + canActivate: [AuthGuard], + data: { + permission: 'Recipes', + }, + resolve: { + item: RecipeResolver, + }, + }, +]; + +@NgModule({ + imports: [CommonModule, RouterModule.forChild(recipeRoutes)], + exports: [RouterModule], + providers: [RecipeListResolver, RecipeResolver], +}) +export class RecipeRoutingModule {} diff --git a/overlord/src/app/recipe/recipe.module.spec.ts b/overlord/src/app/recipe/recipe.module.spec.ts new file mode 100644 index 00000000..330822ca --- /dev/null +++ b/overlord/src/app/recipe/recipe.module.spec.ts @@ -0,0 +1,13 @@ +import { RecipeModule } from './recipe.module'; + +describe('RoleModule', () => { + let roleModule: RecipeModule; + + beforeEach(() => { + roleModule = new RecipeModule(); + }); + + it('should create an instance', () => { + expect(roleModule).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/recipe/recipe.module.ts b/overlord/src/app/recipe/recipe.module.ts new file mode 100644 index 00000000..848a2c17 --- /dev/null +++ b/overlord/src/app/recipe/recipe.module.ts @@ -0,0 +1,68 @@ +import { CdkTableModule } from '@angular/cdk/table'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MomentDateAdapter } from '@angular/material-moment-adapter'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; + +import { SharedModule } from '../shared/shared.module'; + +import { RecipeDetailComponent } from './recipe-detail/recipe-detail.component'; +import { RecipeListComponent } from './recipe-list/recipe-list.component'; +import { RecipeRoutingModule } from './recipe-routing.module'; + +export const MY_FORMATS = { + parse: { + dateInput: 'DD-MMM-YYYY', + }, + display: { + dateInput: 'DD-MMM-YYYY', + monthYearLabel: 'MMM YYYY', + dateA11yLabel: 'DD-MMM-YYYY', + monthYearA11yLabel: 'MMM YYYY', + }, +}; + +@NgModule({ + imports: [ + CommonModule, + CdkTableModule, + FlexLayoutModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatDividerModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatSortModule, + MatTableModule, + ReactiveFormsModule, + SharedModule, + RecipeRoutingModule, + MatAutocompleteModule, + MatDatepickerModule, + MatSelectModule, + ], + declarations: [RecipeListComponent, RecipeDetailComponent], + providers: [ + { provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] }, + { provide: MAT_DATE_FORMATS, useValue: MY_FORMATS }, + ], +}) +export class RecipeModule {} diff --git a/overlord/src/app/recipe/recipe.service.spec.ts b/overlord/src/app/recipe/recipe.service.spec.ts new file mode 100644 index 00000000..d17f5b2c --- /dev/null +++ b/overlord/src/app/recipe/recipe.service.spec.ts @@ -0,0 +1,17 @@ +import { HttpClientModule } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; + +import { RecipeService } from './recipe.service'; + +describe('ProductService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientModule], + providers: [RecipeService], + }); + }); + + it('should be created', inject([RecipeService], (service: RecipeService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/overlord/src/app/recipe/recipe.service.ts b/overlord/src/app/recipe/recipe.service.ts new file mode 100644 index 00000000..0e0182aa --- /dev/null +++ b/overlord/src/app/recipe/recipe.service.ts @@ -0,0 +1,77 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; +import { ProductSku } from '../core/product-sku'; + +import { Recipe } from './recipe'; + +const url = '/api/recipes'; +const serviceName = 'RecipeService'; + +@Injectable({ providedIn: 'root' }) +export class RecipeService { + constructor(private http: HttpClient, private log: ErrorLoggerService) {} + + get(id: string | null): Observable { + const getUrl: string = id === null ? `${url}` : `${url}/${id}`; + return this.http + .get(getUrl) + .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable; + } + + list(): Observable { + return this.http + .get(`${url}/list`) + .pipe(catchError(this.log.handleError(serviceName, 'getList'))) as Observable; + } + + save(recipe: Recipe): Observable { + return this.http + .post(`${url}`, recipe) + .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable; + } + + update(recipe: Recipe): Observable { + return this.http + .put(`${url}/${recipe.id}`, recipe) + .pipe(catchError(this.log.handleError(serviceName, 'update'))) as Observable; + } + + saveOrUpdate(recipe: Recipe): Observable { + if (!recipe.id) { + return this.save(recipe); + } + return this.update(recipe); + } + + delete(id: string): Observable { + return this.http + .delete(`${url}/${id}`) + .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; + } + + getIngredientDetails( + id: string, + startDate: string | null, + finishDate: string | null, + ): Observable { + const getUrl: string = `${url}/ingredient-details/${id}`; + const options = { + params: new HttpParams(), + }; + if (startDate !== null) { + options.params = options.params.set('s', startDate); + } + if (finishDate !== null) { + options.params = options.params.set('f', finishDate); + } + return this.http + .get(getUrl, options) + .pipe( + catchError(this.log.handleError(serviceName, `get id=${id}`)), + ) as Observable; + } +} diff --git a/overlord/src/app/recipe/recipe.ts b/overlord/src/app/recipe/recipe.ts new file mode 100644 index 00000000..582de4fd --- /dev/null +++ b/overlord/src/app/recipe/recipe.ts @@ -0,0 +1,27 @@ +import { ProductSku } from '../core/product-sku'; + +import { RecipeItem } from './recipe-item'; + +export class Recipe { + id: string | undefined; + sku: ProductSku; + recipeYield: number; + costPrice: number; + salePrice: number; + notes: string; + items: RecipeItem[]; + validFrom: string; + validTill: string; + + public constructor(init?: Partial) { + this.sku = new ProductSku(); + this.recipeYield = 0; + this.salePrice = 0; + this.costPrice = 0; + this.notes = ''; + this.items = []; + this.validFrom = ''; + this.validTill = ''; + Object.assign(this, init); + } +}