From 30e3288b1e3b876c683bac6457cb820376835061 Mon Sep 17 00:00:00 2001 From: tanshu Date: Tue, 2 Nov 2021 13:50:35 +0530 Subject: [PATCH] DB Normalization: Moved fractionUnits back to Product from SKU as it is better suited there. Feature: Created the ProductSku schema for the product/sku autocomplete --- .../versions/c39eb451a683_fraction_units.py | 50 +++++++ brewman/brewman/models/batch.py | 3 +- brewman/brewman/models/product.py | 16 +- brewman/brewman/models/stock_keeping_unit.py | 34 +---- brewman/brewman/routers/batch.py | 4 +- .../brewman/routers/employee_attendance.py | 47 +++--- brewman/brewman/routers/issue.py | 8 +- brewman/brewman/routers/product.py | 139 ++++++++++-------- .../brewman/routers/reports/profit_loss.py | 4 +- brewman/brewman/schemas/product.py | 3 +- brewman/brewman/schemas/product_sku.py | 20 +++ brewman/brewman/schemas/stock_keeping_unit.py | 4 +- docker/.gitignore | 2 + overlord/src/app/core/batch.ts | 5 +- overlord/src/app/core/product-sku.ts | 24 +++ overlord/src/app/core/product.ts | 10 +- .../product-ledger.component.ts | 5 +- .../product-detail-dialog.component.html | 6 +- .../product-detail-dialog.component.ts | 13 +- .../product-detail.component.html | 40 ++--- .../product-detail.component.ts | 38 ++--- .../product-list/product-list.component.html | 4 +- overlord/src/app/product/product.service.ts | 29 +++- .../app/purchase/purchase-dialog.component.ts | 9 +- .../src/app/purchase/purchase.component.ts | 17 +-- .../rate-contract-detail.component.ts | 5 +- 26 files changed, 295 insertions(+), 244 deletions(-) create mode 100644 brewman/alembic/versions/c39eb451a683_fraction_units.py create mode 100644 brewman/brewman/schemas/product_sku.py create mode 100644 docker/.gitignore create mode 100644 overlord/src/app/core/product-sku.ts diff --git a/brewman/alembic/versions/c39eb451a683_fraction_units.py b/brewman/alembic/versions/c39eb451a683_fraction_units.py new file mode 100644 index 00000000..ae332994 --- /dev/null +++ b/brewman/alembic/versions/c39eb451a683_fraction_units.py @@ -0,0 +1,50 @@ +"""fraction units + +Revision ID: c39eb451a683 +Revises: 7ba0aff64237 +Create Date: 2021-11-01 10:05:46.057929 + +""" +import sqlalchemy as sa + +from alembic import op +from sqlalchemy import Unicode, column, select, table + +# revision identifiers, used by Alembic. +from sqlalchemy.dialects.postgresql import UUID + + +revision = "c39eb451a683" +down_revision = "7ba0aff64237" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "products", + sa.Column("fraction_units", sa.Unicode(length=255), nullable=False, server_default=""), + ) + product = table( + "products", + column("id", UUID(as_uuid=True)), + column("fraction_units", Unicode(255)), + ) + sku = table( + "stock_keeping_units", + column("product_id", UUID(as_uuid=True)), + column("fraction_units", Unicode(255)), + ) + op.execute( + product.update().values(fraction_units=select(sku.c.fraction_units).where(sku.c.product_id == product.c.id)) + ) + + op.drop_index("unique_true_is_default", table_name="stock_keeping_units") + op.drop_column("stock_keeping_units", "is_default") + op.drop_column("stock_keeping_units", "fraction_units") + # ### end Alembic commands ### + + +def downgrade(): + pass diff --git a/brewman/brewman/models/batch.py b/brewman/brewman/models/batch.py index fb7600b8..1ee6b5fd 100644 --- a/brewman/brewman/models/batch.py +++ b/brewman/brewman/models/batch.py @@ -1,6 +1,7 @@ import uuid from datetime import date +from typing import Optional from sqlalchemy import Column, Date, ForeignKey, Numeric, select from sqlalchemy.dialects.postgresql import UUID @@ -51,7 +52,7 @@ class Batch(Base): return self.quantity_remaining * self.rate * (1 + self.tax) * (1 - self.discount) @classmethod - def list(cls, q: str, include_nil: bool, date_: date, db: Session): + def list(cls, q: str, include_nil: bool, date_: Optional[date], db: Session): query = ( select(cls) .join(cls.sku) diff --git a/brewman/brewman/models/product.py b/brewman/brewman/models/product.py index 1426846f..b554c059 100644 --- a/brewman/brewman/models/product.py +++ b/brewman/brewman/models/product.py @@ -1,11 +1,10 @@ import uuid -from sqlalchemy import Boolean, Column, ForeignKey, Integer, Unicode, desc, func, select +from sqlalchemy import Boolean, Column, ForeignKey, Integer, Unicode from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Session, relationship +from sqlalchemy.orm import relationship from .meta import Base -from .stock_keeping_unit import StockKeepingUnit class Product(Base): @@ -14,6 +13,7 @@ class Product(Base): id = Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) code = Column("code", Integer, unique=True) name = Column("name", Unicode(255), nullable=False, unique=True) + fraction_units = Column("fraction_units", Unicode(255), nullable=False) product_group_id = Column( "product_group_id", UUID(as_uuid=True), @@ -26,15 +26,15 @@ class Product(Base): is_purchased = Column("is_purchased", Boolean, nullable=False) is_sold = Column("is_sold", Boolean, nullable=False) - skus = relationship("StockKeepingUnit", back_populates="product", order_by=desc(StockKeepingUnit.is_default)) + skus = relationship("StockKeepingUnit", back_populates="product") product_group = relationship("ProductGroup", back_populates="products") - recipes = relationship("Recipe", back_populates="product") account = relationship("Account", primaryjoin="Account.id==Product.account_id", back_populates="products") def __init__( self, code=None, name=None, + fraction_units=None, product_group_id=None, account_id=None, is_active=None, @@ -45,6 +45,7 @@ class Product(Base): ): self.code = code self.name = name + self.fraction_units = fraction_units self.product_group_id = product_group_id self.account_id = account_id self.is_active = is_active @@ -53,11 +54,6 @@ class Product(Base): self.id = id_ self.is_fixture = is_fixture - def create(self, db: Session): - self.code = db.execute(select(func.coalesce(func.max(Product.code), 0) + 1)).scalar_one() - db.add(self) - return self - @classmethod def suspense(cls): return uuid.UUID("aa79a643-9ddc-4790-ac7f-a41f9efb4c15") diff --git a/brewman/brewman/models/stock_keeping_unit.py b/brewman/brewman/models/stock_keeping_unit.py index 20b82c19..e1ff4848 100644 --- a/brewman/brewman/models/stock_keeping_unit.py +++ b/brewman/brewman/models/stock_keeping_unit.py @@ -1,15 +1,6 @@ import uuid -from sqlalchemy import ( - Boolean, - Column, - ForeignKey, - Index, - Numeric, - Unicode, - UniqueConstraint, - text, -) +from sqlalchemy import Column, ForeignKey, Numeric, Unicode, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -18,50 +9,37 @@ from .meta import Base class StockKeepingUnit(Base): __tablename__ = "stock_keeping_units" - __table_args__ = ( - UniqueConstraint("product_id", "units"), - Index( - "unique_true_is_default", - "product_id", - unique=True, - postgresql_where=text("is_default = true"), - ), - ) + __table_args__ = (UniqueConstraint("product_id", "units"),) id = Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) product_id = Column("product_id", UUID(as_uuid=True), ForeignKey("products.id"), nullable=False) - is_default = Column("is_default", Boolean, nullable=False) units = Column("units", Unicode(255), nullable=False) fraction = Column("fraction", Numeric(precision=15, scale=5), nullable=False) - fraction_units = Column("fraction_units", Unicode(255), nullable=False) product_yield = Column("product_yield", Numeric(precision=15, scale=5), nullable=False) - price = Column("cost_price", Numeric(precision=15, scale=2), nullable=False) + cost_price = Column("cost_price", Numeric(precision=15, scale=2), nullable=False) sale_price = Column("sale_price", Numeric(precision=15, scale=2), nullable=False) product = relationship("Product", back_populates="skus") batches = relationship("Batch", back_populates="sku") + recipes = relationship("Recipe", back_populates="sku") def __init__( self, product_id=None, - is_default=None, units=None, fraction=None, - fraction_units=None, product_yield=None, - price=None, + cost_price=None, sale_price=None, id_=None, product=None, ): if product_id is not None: self.product_id = product_id - self.is_default = is_default self.units = units self.fraction = fraction - self.fraction_units = fraction_units self.product_yield = product_yield - self.price = price + self.cost_price = cost_price self.sale_price = sale_price self.id = id_ if product is not None: diff --git a/brewman/brewman/routers/batch.py b/brewman/brewman/routers/batch.py index 2628c12f..9ea043ac 100644 --- a/brewman/brewman/routers/batch.py +++ b/brewman/brewman/routers/batch.py @@ -17,10 +17,10 @@ def batch_term( d: str = None, current_user: UserToken = Depends(get_user), ): - date = None if not d else datetime.datetime.strptime(d, "%d-%b-%Y") + date_ = None if not d else datetime.datetime.strptime(d, "%d-%b-%Y") list_ = [] with SessionFuture() as db: - for item in Batch.list(q, include_nil=False, date_=date, db=db): + for item in Batch.list(q, include_nil=False, date_=date_, db=db): text = ( f"{item.sku.product.name} ({item.sku.units}) {item.quantity_remaining:.2f}@" f"{item.rate:.2f} from {item.name.strftime('%d-%b-%Y')}" diff --git a/brewman/brewman/routers/employee_attendance.py b/brewman/brewman/routers/employee_attendance.py index ecceaf2f..17a66e21 100644 --- a/brewman/brewman/routers/employee_attendance.py +++ b/brewman/brewman/routers/employee_attendance.py @@ -1,6 +1,7 @@ import uuid from datetime import date, datetime +from typing import List import brewman.schemas.employee_attendance as schemas @@ -41,27 +42,27 @@ def employee_attendance_report( s: str = None, f: str = None, user: UserToken = Security(get_user, scopes=["attendance"]), -): +) -> schemas.EmployeeAttendance: with SessionFuture() as db: employee: Employee = db.execute(select(Employee).where(Employee.id == id_)).scalar_one() - start_date = s if s is not None else get_start_date(request.session) - finish_date = f if f is not None else get_finish_date(request.session) - info = { - "startDate": start_date, - "finishDate": finish_date, - "employee": {"id": employee.id, "name": employee.name}, - } - start_date = datetime.strptime(start_date, "%d-%b-%Y").date() - finish_date = datetime.strptime(finish_date, "%d-%b-%Y").date() + start_date = datetime.strptime(s or get_start_date(request.session), "%d-%b-%Y").date() + finish_date = datetime.strptime(f or get_finish_date(request.session), "%d-%b-%Y").date() + info = schemas.EmployeeAttendance( + startDate=start_date, + finishDate=finish_date, + employee=schemas.AccountLink(id=employee.id, name=employee.name), + ) start_date = employee.joining_date if employee.joining_date > start_date else start_date finish_date = ( employee.leaving_date if not employee.is_active and employee.leaving_date < finish_date else finish_date ) - info["body"] = employee_attendance(employee, start_date, finish_date, db) + info.body = employee_attendance(employee, start_date, finish_date, db) return info -def employee_attendance(employee: Employee, start_date: date, finish_date: date, db: Session): +def employee_attendance( + employee: Employee, start_date: date, finish_date: date, db: Session +) -> List[schemas.EmployeeAttendanceItem]: list_ = [] for item in date_range(start_date, finish_date, inclusive=True): att = ( @@ -95,16 +96,10 @@ def save_employee_attendance( id_: uuid.UUID, data: schemas.EmployeeAttendance, user: UserToken = Security(get_user, scopes=["attendance"]), -): - start_date = None - finish_date = None +) -> schemas.EmployeeAttendance: with SessionFuture() as db: employee: Employee = db.execute(select(Employee).where(Employee.id == id_)).scalar_one() for item in data.body: - if start_date is None: - start_date = item.date_ - finish_date = item.date_ - attendance_type = item.attendance_type.id_ if attendance_type != 0: attendance = Attendance( @@ -115,9 +110,11 @@ def save_employee_attendance( ) attendance.create(db) db.commit() - return { - "startDate": start_date.strftime("%d-%b-%Y"), - "finishDate": finish_date.strftime("%d-%b-%Y"), - "employee": {"id": employee.id, "name": employee.name}, - "body": employee_attendance(employee, start_date, finish_date, db), - } + start_date = min(i.date_ for i in data.body) + finish_date = max(i.date_ for i in data.body) + return schemas.EmployeeAttendance( + startDate=start_date, + finishDate=finish_date, + employee={"id": employee.id, "name": employee.name}, + body=employee_attendance(employee, start_date, finish_date, db), + ) diff --git a/brewman/brewman/routers/issue.py b/brewman/brewman/routers/issue.py index fc18efa1..792de472 100644 --- a/brewman/brewman/routers/issue.py +++ b/brewman/brewman/routers/issue.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime from decimal import Decimal -from typing import List, Optional +from typing import List, Optional, Tuple import brewman.schemas.input as schema_in import brewman.schemas.voucher as output @@ -67,7 +67,7 @@ def save_route( ) -def save(data: schema_in.IssueIn, user: UserToken, db: Session) -> (Voucher, Optional[bool]): +def save(data: schema_in.IssueIn, user: UserToken, db: Session) -> Tuple[Voucher, Optional[bool]]: product_accounts = ( select(Product.account_id) .join(Product.skus) @@ -198,7 +198,9 @@ def update_route( ) -def update_voucher(id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session) -> (Voucher, Optional[bool]): +def update_voucher( + id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session +) -> Tuple[Voucher, Optional[bool]]: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() product_accounts = ( select(Product.account_id) diff --git a/brewman/brewman/routers/product.py b/brewman/brewman/routers/product.py index e320d6e0..727be872 100644 --- a/brewman/brewman/routers/product.py +++ b/brewman/brewman/routers/product.py @@ -9,7 +9,7 @@ import brewman.schemas.product as schemas from fastapi import APIRouter, Depends, HTTPException, Security, status from sqlalchemy import delete, desc, func, or_, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, contains_eager +from sqlalchemy.orm import Session, contains_eager, joinedload from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture @@ -19,6 +19,7 @@ from ..models.product import Product from ..models.rate_contract import RateContract from ..models.rate_contract_item import RateContractItem from ..models.stock_keeping_unit import StockKeepingUnit +from ..schemas.product_sku import ProductSku from ..schemas.user import UserToken @@ -34,30 +35,23 @@ def save( with SessionFuture() as db: item = Product( name=data.name, + fraction_units=data.fraction_units, product_group_id=data.product_group.id_, account_id=Account.all_purchases(), is_active=data.is_active, is_purchased=data.is_purchased, is_sold=data.is_sold, - ).create(db) - if len([s for s in data.skus if s.is_default is True]) != 1: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Exactly one default sku is needed" - ) - if len(set([s.fraction_units for s in data.skus])) != 1: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="All skus need to have the same fraction unit", - ) + ) + item.code = db.execute(select(func.coalesce(func.max(Product.code), 0) + 1)).scalar_one() + db.add(item) + for sku in data.skus: db.add( StockKeepingUnit( - is_default=sku.is_default, units=sku.units, fraction=round(sku.fraction, 5), - fraction_units=sku.fraction_units, product_yield=round(sku.product_yield, 5), - price=round(sku.price, 2), + cost_price=round(sku.cost_price, 2), sale_price=round(sku.sale_price, 2), product=item, ) @@ -86,33 +80,21 @@ def update_route( detail=f"{item.name} is a fixture and cannot be edited or deleted.", ) item.name = data.name + item.fraction_units = data.fraction_units item.product_group_id = data.product_group.id_ item.account_id = Account.all_purchases() item.is_active = data.is_active item.is_purchased = data.is_purchased item.is_sold = data.is_sold - if len([sku for sku in data.skus if sku.is_default is True]) != 1: - raise HTTPException( - status_code=status.HTTP_423_LOCKED, - detail="There needs to be exactly 1 default SKU", - ) - if len(set([s.fraction_units for s in data.skus])) != 1: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="All skus need to have the same fraction unit", - ) - default_sku = next(s.units for s in data.skus if s.is_default is True) for i in range(len(item.skus), 0, -1): sku = item.skus[i - 1] index = next((idx for (idx, d) in enumerate(data.skus) if d.id_ == sku.id), None) if index is not None: new_sku = data.skus.pop(index) - sku.is_default = False sku.units = new_sku.units sku.fraction = round(new_sku.fraction, 5) - sku.fraction_units = new_sku.fraction_units sku.product_yield = round(new_sku.product_yield, 5) - sku.price = round(new_sku.price, 2) + sku.cost_price = round(new_sku.cost_price, 2) sku.sale_price = round(new_sku.sale_price, 2) else: count: Decimal = db.execute(select(func.count()).where(Batch.sku_id == sku.id)).scalar_one() @@ -125,20 +107,15 @@ def update_route( db.delete(sku) for sku in data.skus: new_sku = StockKeepingUnit( - is_default=False, units=sku.units, fraction=round(sku.fraction, 5), - fraction_units=sku.fraction_units, product_yield=round(sku.product_yield, 5), - price=round(sku.price, 2), + cost_price=round(sku.cost_price, 2), sale_price=round(sku.sale_price, 2), product=item, ) db.add(new_sku) item.skus.append(new_sku) - db.flush() - default_sku = next(s for s in item.skus if s.units == default_sku) - default_sku.is_default = True db.commit() return product_info(item) except SQLAlchemyError as e: @@ -187,26 +164,33 @@ def show_list(user: UserToken = Depends(get_user)) -> List[schemas.Product]: product_info(item) for item in db.execute( select(Product) + .join(Product.product_group) + .join(Product.skus) .order_by(desc(Product.is_active)) .order_by(Product.product_group_id) .order_by(Product.name) + .options( + joinedload(Product.skus, innerjoin=True), + joinedload(Product.product_group, innerjoin=True), + contains_eager(Product.skus), + contains_eager(Product.product_group), + ) ) + .unique() .scalars() .all() ] -@router.get("/query") -async def show_term( +@router.get("/q-sku", response_model=List[ProductSku]) +async def show_term_sku( q: str = None, # Query a: bool = None, # Active p: bool = None, # Is Purchased? - e: bool = False, # Extended - s: bool = False, # List separate SKUs v: Optional[uuid.UUID] = None, # Vendor d: Optional[str] = None, # Date current_user: UserToken = Depends(get_user), -): +) -> List[ProductSku]: list_ = [] with SessionFuture() as db: query_ = select(Product).join(Product.skus).options(contains_eager(Product.skus)) @@ -223,33 +207,59 @@ async def show_term( query_ = query_.order_by(Product.name) for item in db.execute(query_).unique().scalars().all(): - skus = item.skus if s else item.skus[:1] - for sku in skus: + for sku in item.skus: # type: StockKeepingUnit rc_price = get_rc_price(item.id, d, v, db) list_.append( - { - "id": sku.id if s else item.id, - "name": item.name, - "price": sku.price if rc_price is None else rc_price, - "units": sku.units if s else "", - "fraction": sku.fraction, - "fractionUnits": sku.fraction_units, - "productYield": sku.product_yield, - "isSold": item.is_sold, - "salePrice": sku.sale_price, - "isRateContracted": False if rc_price is None else True, - } - if e - else { - "id": sku.id if s else item.id, - "name": f"{item.name} ({sku.units})" if s else item.name, - "price": sku.price if rc_price is None else rc_price, - "isRateContracted": False if rc_price is None else True, - } + ProductSku( + id=sku.id, + name=f"{item.name} ({sku.units})", + fractionUnits=item.fraction_units, + costPrice=sku.cost_price if rc_price is None else rc_price, + salePrice=sku.sale_price, + fraction=sku.fraction, + productYield=sku.product_yield, + isRateContracted=False if rc_price is None else True, + ) ) return list_ +@router.get("/q-product", response_model=List[ProductSku]) +async def show_term_product( + q: str = None, # Query + a: bool = None, # Active + p: bool = None, # Is Purchased? + current_user: UserToken = Depends(get_user), +) -> List[ProductSku]: + list_ = [] + with SessionFuture() as db: + query_ = select(Product) + if a is not None: + query_ = query_.filter(Product.is_active == a) + if p is not None: + query_ = query_.filter(Product.is_purchased == p) + if q is not None: + for item in q.split(): + if item.strip() != "": + query_ = query_.filter(Product.name.ilike(f"%{item}%")) + query_ = query_.order_by(Product.name) + + for item in db.execute(query_).unique().scalars().all(): + list_.append( + ProductSku( + id=item.id, + name=item.name, + fractionUnits=item.fraction_units, + costPrice=0, + salePrice=0, + fraction=1, + productYield=1, + isRateContracted=False, + ) + ) + return list_ + + @router.get("/{id_}", response_model=schemas.Product) def show_id( id_: uuid.UUID, @@ -261,19 +271,18 @@ def show_id( def product_info(product: Product) -> schemas.Product: - product = schemas.Product( + return schemas.Product( id=product.id, code=product.code, name=product.name, + fractionUnits=product.fraction_units, skus=[ schemas.StockKeepingUnit( id=sku.id, - isDefault=sku.is_default, units=sku.units, fraction=sku.fraction, - fractionUnits=sku.fraction_units, productYield=sku.product_yield, - price=sku.price, + costPrice=sku.cost_price, salePrice=sku.sale_price, ) for sku in product.skus @@ -284,12 +293,12 @@ def product_info(product: Product) -> schemas.Product: isSold=product.is_sold, productGroup=schemas.ProductGroupLink(id=product.product_group.id, name=product.product_group.name), ) - return product def product_blank() -> schemas.ProductBlank: return schemas.ProductBlank( name="", + fractionUnits="", skus=[], isActive=True, isPurchased=True, diff --git a/brewman/brewman/routers/reports/profit_loss.py b/brewman/brewman/routers/reports/profit_loss.py index 9fb0bfa2..47d56732 100644 --- a/brewman/brewman/routers/reports/profit_loss.py +++ b/brewman/brewman/routers/reports/profit_loss.py @@ -1,6 +1,6 @@ from datetime import date, datetime from decimal import Decimal -from typing import List +from typing import List, Tuple import brewman.schemas.profit_loss as schemas @@ -59,7 +59,7 @@ def report_data( def build_profit_loss( start_date: date, finish_date: date, db: Session -) -> (List[schemas.ProfitLossItem], schemas.ProfitLossItem): +) -> Tuple[List[schemas.ProfitLossItem], schemas.ProfitLossItem]: profit_type_list = ( db.execute(select(AccountType.id).where(AccountType.balance_sheet == False)).scalars().all() # noqa: E712 ) diff --git a/brewman/brewman/schemas/product.py b/brewman/brewman/schemas/product.py index 089a948e..93b47ec1 100644 --- a/brewman/brewman/schemas/product.py +++ b/brewman/brewman/schemas/product.py @@ -19,6 +19,7 @@ class ProductLink(BaseModel): class ProductIn(BaseModel): name: str = Field(..., min_length=1) + fraction_units: str = Field(..., min_length=1) skus: List[StockKeepingUnit] product_group: ProductGroupLink = Field(...) is_active: bool @@ -26,7 +27,6 @@ class ProductIn(BaseModel): is_sold: bool class Config: - fields = {"id_": "id"} anystr_strip_whitespace = True alias_generator = to_camel @@ -39,6 +39,7 @@ class Product(ProductIn): class ProductBlank(ProductIn): name: str + fraction_units: str skus: List[StockKeepingUnit] product_group: Optional[ProductGroupLink] # type: ignore[assignment] is_fixture: bool diff --git a/brewman/brewman/schemas/product_sku.py b/brewman/brewman/schemas/product_sku.py new file mode 100644 index 00000000..45a09588 --- /dev/null +++ b/brewman/brewman/schemas/product_sku.py @@ -0,0 +1,20 @@ +import uuid + +from decimal import Decimal + +from brewman.schemas import to_camel +from pydantic import BaseModel, Field + + +class ProductSku(BaseModel): + id_: uuid.UUID = Field(...) + name: str + fraction_units: str + cost_price: Decimal + sale_price: Decimal + fraction: Decimal + product_yield: Decimal + is_rate_contracted: bool + + class Config: + alias_generator = to_camel diff --git a/brewman/brewman/schemas/stock_keeping_unit.py b/brewman/brewman/schemas/stock_keeping_unit.py index 8204ed05..9cdd2079 100644 --- a/brewman/brewman/schemas/stock_keeping_unit.py +++ b/brewman/brewman/schemas/stock_keeping_unit.py @@ -10,12 +10,10 @@ from . import to_camel class StockKeepingUnit(BaseModel): id_: Optional[uuid.UUID] - is_default: bool units: str = Field(..., min_length=1) fraction: Decimal = Field(ge=1, default=1) - fraction_units: str product_yield: Decimal = Field(gt=0, le=1, default=1) - price: Decimal = Field(ge=0, multiple_of=0.01, default=0) + cost_price: Decimal = Field(ge=0, multiple_of=0.01, default=0) sale_price: Decimal = Field(ge=0, multiple_of=0.01, default=0) class Config: diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 00000000..b5a2328d --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,2 @@ +app/package.json +app/pyproject.toml diff --git a/overlord/src/app/core/batch.ts b/overlord/src/app/core/batch.ts index 487c2b97..3aaf0c0b 100644 --- a/overlord/src/app/core/batch.ts +++ b/overlord/src/app/core/batch.ts @@ -1,4 +1,5 @@ import { Product } from './product'; +import { ProductSku } from './product-sku'; export class Batch { id: string | null; @@ -7,7 +8,7 @@ export class Batch { tax: number; discount: number; rate: number; - sku: Product; + sku: ProductSku; public constructor(init?: Partial) { this.id = null; @@ -16,7 +17,7 @@ export class Batch { this.tax = 0; this.discount = 0; this.rate = 0; - this.sku = new Product(); + this.sku = new ProductSku(); Object.assign(this, init); } } diff --git a/overlord/src/app/core/product-sku.ts b/overlord/src/app/core/product-sku.ts new file mode 100644 index 00000000..f4f3e1b7 --- /dev/null +++ b/overlord/src/app/core/product-sku.ts @@ -0,0 +1,24 @@ +export class ProductSku { + id: string; + name: string; + costPrice: number; + salePrice: number; + fraction: number; + productYield: number; + fractionUnits: string; + + isRateContracted: boolean; + + public constructor(init?: Partial) { + this.id = ''; + this.name = ''; + this.costPrice = 0; + this.salePrice = 0; + this.fraction = 0; + this.productYield = 0; + this.fractionUnits = ''; + this.isRateContracted = false; + + Object.assign(this, init); + } +} diff --git a/overlord/src/app/core/product.ts b/overlord/src/app/core/product.ts index 793fece9..59db1501 100644 --- a/overlord/src/app/core/product.ts +++ b/overlord/src/app/core/product.ts @@ -1,21 +1,17 @@ import { ProductGroup } from './product-group'; export class StockKeepingUnit { - isDefault: boolean; units: string; fraction: number; - fractionUnits: string; productYield: number; - price: number; + costPrice: number; salePrice: number; public constructor(init?: Partial) { - this.isDefault = false; this.units = ''; this.fraction = 1; - this.fractionUnits = ''; this.productYield = 1; - this.price = 0; + this.costPrice = 0; this.salePrice = 0; Object.assign(this, init); } @@ -26,7 +22,6 @@ export class Product { code: number; name: string; skus: StockKeepingUnit[]; - price: number | undefined; fractionUnits: string | undefined; isActive: boolean; @@ -34,7 +29,6 @@ export class Product { isPurchased: boolean; isSold: boolean; productGroup?: ProductGroup; - isRateContracted?: boolean; public constructor(init?: Partial) { this.code = 0; diff --git a/overlord/src/app/product-ledger/product-ledger.component.ts b/overlord/src/app/product-ledger/product-ledger.component.ts index 52c147fc..816ae6a6 100644 --- a/overlord/src/app/product-ledger/product-ledger.component.ts +++ b/overlord/src/app/product-ledger/product-ledger.component.ts @@ -9,6 +9,7 @@ import { 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 { ProductService } from '../product/product.service'; import { ToCsvService } from '../shared/to-csv.service'; @@ -49,7 +50,7 @@ export class ProductLedgerComponent implements OnInit, AfterViewInit { 'runningAmount', ]; - products: Observable; + products: Observable; constructor( private route: ActivatedRoute, @@ -71,7 +72,7 @@ export class ProductLedgerComponent implements OnInit, AfterViewInit { debounceTime(150), distinctUntilChanged(), switchMap((x) => - x === null ? observableOf([]) : this.productSer.autocomplete(x, null, false, false), + x === null ? observableOf([]) : this.productSer.autocompleteProduct(x, null), ), ); } diff --git a/overlord/src/app/product/product-detail/product-detail-dialog.component.html b/overlord/src/app/product/product-detail/product-detail-dialog.component.html index 4e7a07f7..3b7a9de8 100644 --- a/overlord/src/app/product/product-detail/product-detail-dialog.component.html +++ b/overlord/src/app/product/product-detail/product-detail-dialog.component.html @@ -16,10 +16,6 @@ Fraction - - Fraction Units - - Yield @@ -30,7 +26,7 @@ matInput type="number" placeholder="{{ data.isPurchased ? 'Purchase Price' : 'Cost Price' }}" - formControlName="price" + formControlName="costPrice" /> diff --git a/overlord/src/app/product/product-detail/product-detail-dialog.component.ts b/overlord/src/app/product/product-detail/product-detail-dialog.component.ts index a734ebfc..2bb82fc4 100644 --- a/overlord/src/app/product/product-detail/product-detail-dialog.component.ts +++ b/overlord/src/app/product/product-detail/product-detail-dialog.component.ts @@ -21,9 +21,8 @@ export class ProductDetailDialogComponent implements OnInit { this.form = this.fb.group({ units: '', fraction: '', - fractionUnits: '', productYield: '', - price: '', + costPrice: '', salePrice: '', }); } @@ -32,9 +31,8 @@ export class ProductDetailDialogComponent implements OnInit { this.form.setValue({ units: this.data.item.units, fraction: '' + this.data.item.fraction, - fractionUnits: this.data.item.fractionUnits, productYield: '' + this.data.item.productYield, - price: '' + this.data.item.price, + costPrice: '' + this.data.item.costPrice, salePrice: '' + this.data.item.salePrice, }); } @@ -49,8 +47,8 @@ export class ProductDetailDialogComponent implements OnInit { if (productYield < 0 || productYield > 1) { return; } - const price = +formValue.price; - if (price < 0) { + const costPrice = +formValue.costPrice; + if (costPrice < 0) { return; } const salePrice = +formValue.salePrice; @@ -59,9 +57,8 @@ export class ProductDetailDialogComponent implements OnInit { } this.data.item.units = formValue.units; this.data.item.fraction = fraction; - this.data.item.fractionUnits = formValue.fractionUnits; this.data.item.productYield = productYield; - this.data.item.price = price; + this.data.item.costPrice = costPrice; this.data.item.salePrice = salePrice; this.dialogRef.close(this.data.item); } diff --git a/overlord/src/app/product/product-detail/product-detail.component.html b/overlord/src/app/product/product-detail/product-detail.component.html index d7a5eb3f..cb347e16 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.html +++ b/overlord/src/app/product/product-detail/product-detail.component.html @@ -24,10 +24,14 @@ fxLayoutGap="20px" fxLayoutGap.lt-md="0px" > - + Name + + Fraction Units + +
Fraction - - Fraction Units - - Yield @@ -87,7 +87,7 @@ matInput type="number" placeholder="{{ item.isPurchased ? 'Purchase Price' : 'Cost Price' }}" - formControlName="price" + formControlName="costPrice" /> @@ -98,18 +98,6 @@
- - - Default - - - - - Units @@ -122,22 +110,18 @@ {{ row.fraction }} - - - Fraction Units - {{ row.fractionUnits }} - - Yield {{ row.productYield }} - - - Price - {{ row.price | currency: 'INR' }} + + + Cost Price + {{ + row.costPrice | currency: 'INR' + }} diff --git a/overlord/src/app/product/product-detail/product-detail.component.ts b/overlord/src/app/product/product-detail/product-detail.component.ts index c7b12fa1..b23516ea 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.ts +++ b/overlord/src/app/product/product-detail/product-detail.component.ts @@ -1,6 +1,5 @@ import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; -import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; @@ -27,16 +26,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { dataSource: ProductDetailDatasource = new ProductDetailDatasource(this.skus); item: Product = new Product(); - displayedColumns = [ - 'isDefault', - 'units', - 'fraction', - 'fractionUnits', - 'yield', - 'price', - 'salePrice', - 'action', - ]; + displayedColumns = ['units', 'fraction', 'yield', 'costPrice', 'salePrice', 'action']; constructor( private route: ActivatedRoute, @@ -49,12 +39,12 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.form = this.fb.group({ code: { value: '', disabled: true }, name: '', + fractionUnits: '', addRow: this.fb.group({ units: '', fraction: '', - fractionUnits: '', productYield: '', - price: '', + costPrice: '', salePrice: '', }), isPurchased: '', @@ -79,13 +69,13 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.item = item; this.form.setValue({ code: this.item.code || '(Auto)', - name: this.item.name || '', + name: this.item.name, + fractionUnits: this.item.fractionUnits, addRow: { units: '', fraction: '', - fractionUnits: '', productYield: '', - price: '', + costPrice: '', salePrice: '', }, isPurchased: this.item.isPurchased, @@ -115,8 +105,8 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.toaster.show('Danger', 'Product Yield has to be > 0 and <= 1'); return; } - const price = +formValue.price; - if (price < 0) { + const costPrice = +formValue.costPrice; + if (costPrice < 0) { this.toaster.show('Danger', 'Price has to be >= 0'); return; } @@ -129,9 +119,8 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { new StockKeepingUnit({ units: formValue.units, fraction, - fractionUnits: formValue.fractionUnits, productYield, - price, + costPrice, salePrice, }), ); @@ -143,9 +132,8 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { (this.form.get('addRow') as FormControl).reset({ units: '', fraction: '', - fractionUnits: '', productYield: '', - price: '', + costPrice: '', salePrice: '', }); } @@ -216,6 +204,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { getItem(): Product { const formModel = this.form.value; this.item.name = formModel.name; + this.item.fractionUnits = formModel.fractionUnits; this.item.isPurchased = formModel.isPurchased; this.item.isSold = formModel.isSold; this.item.isActive = formModel.isActive; @@ -225,9 +214,4 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.item.productGroup.id = formModel.productGroup; return this.item; } - - changeDefault($event: MatCheckboxChange, row: StockKeepingUnit) { - this.item.skus.forEach((x) => (x.isDefault = false)); - row.isDefault = true; - } } diff --git a/overlord/src/app/product/product-list/product-list.component.html b/overlord/src/app/product/product-list/product-list.component.html index 5160f488..a7e634ad 100644 --- a/overlord/src/app/product/product-list/product-list.component.html +++ b/overlord/src/app/product/product-list/product-list.component.html @@ -39,7 +39,7 @@
  • {{ row.name }} ({{ - showExtended ? sku.fraction + ' ' + sku.fractionUnits + ' = 1 ' : '' + showExtended ? sku.fraction + ' ' + row.fractionUnits + ' = 1 ' : '' }}{{ sku.units }})
  • @@ -53,7 +53,7 @@
    • - {{ sku.price | currency: 'INR' }} + {{ sku.costPrice | currency: 'INR' }}
    diff --git a/overlord/src/app/product/product.service.ts b/overlord/src/app/product/product.service.ts index 6e5d15b5..c75c9977 100644 --- a/overlord/src/app/product/product.service.ts +++ b/overlord/src/app/product/product.service.ts @@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators'; import { ErrorLoggerService } from '../core/error-logger.service'; import { Product } from '../core/product'; +import { ProductSku } from '../core/product-sku'; const url = '/api/products'; const serviceName = 'ProductService'; @@ -51,16 +52,28 @@ export class ProductService { .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable; } - autocomplete( + autocompleteProduct(query: string, isPurchased: boolean | null): Observable { + const options = { + params: new HttpParams().set('q', query), + }; + if (isPurchased !== null) { + options.params = options.params.set('p', isPurchased.toString()); + } + return this.http + .get(`${url}/q-product`, options) + .pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable< + ProductSku[] + >; + } + + autocompleteSku( query: string, isPurchased: boolean | null, - extended: boolean = false, - skus: boolean = true, date?: string, vendorId?: string, - ): Observable { + ): Observable { const options = { - params: new HttpParams().set('q', query).set('e', extended.toString()).set('s', skus), + params: new HttpParams().set('q', query), }; if (isPurchased !== null) { options.params = options.params.set('p', isPurchased.toString()); @@ -69,7 +82,9 @@ export class ProductService { options.params = options.params.set('v', vendorId as string).set('d', date as string); } return this.http - .get(`${url}/query`, options) - .pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable; + .get(`${url}/q-sku`, options) + .pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable< + ProductSku[] + >; } } diff --git a/overlord/src/app/purchase/purchase-dialog.component.ts b/overlord/src/app/purchase/purchase-dialog.component.ts index 9ea6ede5..afa5ecdd 100644 --- a/overlord/src/app/purchase/purchase-dialog.component.ts +++ b/overlord/src/app/purchase/purchase-dialog.component.ts @@ -9,6 +9,7 @@ import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'r import { Batch } from '../core/batch'; import { Inventory } from '../core/inventory'; import { Product } from '../core/product'; +import { ProductSku } from '../core/product-sku'; import { ProductService } from '../product/product.service'; import { MathService } from '../shared/math.service'; @@ -18,9 +19,9 @@ import { MathService } from '../shared/math.service'; styleUrls: ['./purchase-dialog.component.css'], }) export class PurchaseDialogComponent implements OnInit { - products: Observable; + products: Observable; form: FormGroup; - product: Product = new Product(); + product: ProductSku = new ProductSku(); constructor( public dialogRef: MatDialogRef, @@ -42,7 +43,7 @@ export class PurchaseDialogComponent implements OnInit { map((x) => (x !== null && x.length >= 1 ? x : null)), debounceTime(150), distinctUntilChanged(), - switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocomplete(x, true))), + switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocompleteSku(x, true))), ); } @@ -63,7 +64,7 @@ export class PurchaseDialogComponent implements OnInit { productSelected(event: MatAutocompleteSelectedEvent): void { this.product = event.option.value; - (this.form.get('price') as FormControl).setValue(this.product.price); + (this.form.get('price') as FormControl).setValue(this.product.costPrice); } accept(): void { diff --git a/overlord/src/app/purchase/purchase.component.ts b/overlord/src/app/purchase/purchase.component.ts index 56bb0e84..65e97ccc 100644 --- a/overlord/src/app/purchase/purchase.component.ts +++ b/overlord/src/app/purchase/purchase.component.ts @@ -17,6 +17,7 @@ import { Batch } from '../core/batch'; import { DbFile } from '../core/db-file'; import { Inventory } from '../core/inventory'; import { Product } from '../core/product'; +import { ProductSku } from '../core/product-sku'; import { ToasterService } from '../core/toaster.service'; import { User } from '../core/user'; import { Voucher } from '../core/voucher'; @@ -43,13 +44,13 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { dataSource: PurchaseDataSource = new PurchaseDataSource(this.inventoryObservable); form: FormGroup; voucher: Voucher = new Voucher(); - product: Product | null = null; + product: ProductSku | null = null; accBal: AccountBalance | null = null; displayedColumns = ['product', 'quantity', 'rate', 'tax', 'discount', 'amount', 'action']; accounts: Observable; - products: Observable; + products: Observable; constructor( private route: ActivatedRoute, @@ -98,11 +99,9 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { switchMap((x) => x === null ? observableOf([]) - : this.productSer.autocomplete( + : this.productSer.autocompleteSku( x, true, - false, - true, moment(this.form.value.date).format('DD-MMM-YYYY'), this.form.value.account.id, ), @@ -198,7 +197,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { return; } const price = this.product.isRateContracted - ? this.product.price + ? (this.product.costPrice as number) : this.math.parseAmount(formValue.price, 2); const tax = this.product.isRateContracted ? 0 : this.math.parseAmount(formValue.tax, 5); const discount = this.product.isRateContracted @@ -208,7 +207,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { return; } const oldFiltered = this.voucher.inventories.filter( - (x) => x.batch?.sku.id === (this.product as Product).id, + (x) => x.batch?.sku.id === (this.product as ProductSku).id, ); if (oldFiltered.length) { this.toaster.show('Danger', 'Product already added'); @@ -365,10 +364,10 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { } productSelected(event: MatAutocompleteSelectedEvent): void { - const product: Product = event.option.value; + const product: ProductSku = event.option.value; const addRowForm: FormControl = this.form.get('addRow') as FormControl; this.product = product; - (addRowForm.get('price') as FormControl).setValue(product.price); + (addRowForm.get('price') as FormControl).setValue(product.costPrice); if (product.isRateContracted) { (addRowForm.get('price') as FormControl).disable(); (addRowForm.get('tax') as FormControl).disable(); diff --git a/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.ts b/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.ts index ef172cf0..a9ff97ad 100644 --- a/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.ts +++ b/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.ts @@ -10,6 +10,7 @@ import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'r import { Account } from '../../core/account'; import { AccountService } from '../../core/account.service'; 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'; @@ -38,7 +39,7 @@ export class RateContractDetailComponent implements OnInit, AfterViewInit { displayedColumns = ['product', 'price', 'action']; accounts: Observable; - products: Observable; + products: Observable; constructor( private route: ActivatedRoute, @@ -77,7 +78,7 @@ export class RateContractDetailComponent implements OnInit, AfterViewInit { map((x) => (x !== null && x.length >= 1 ? x : null)), debounceTime(150), distinctUntilChanged(), - switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocomplete(x, true))), + switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocompleteSku(x, true))), ); }