From 1647d356c9295a39e25e17d16b3452ff6217df91 Mon Sep 17 00:00:00 2001 From: tanshu Date: Mon, 27 Sep 2021 09:31:58 +0530 Subject: [PATCH] Feature: Added product Stock Keeping Units to prevent duplicate products. A lot of refactoring because of this. Removed: Reset Stock as it was never used and don't think it is even needed with this new batch system. Fix: Incentive update was not working --- .../0670868fe171_stock_keeping_units.py | 160 ++++++++++ brewman/brewman/db/base.py | 1 + brewman/brewman/main.py | 2 - brewman/brewman/models/account_base.py | 1 + brewman/brewman/models/batch.py | 27 +- brewman/brewman/models/inventory.py | 12 +- brewman/brewman/models/product.py | 69 +---- brewman/brewman/models/rate_contract_item.py | 10 +- brewman/brewman/models/stock_keeping_unit.py | 68 +++++ brewman/brewman/models/validations.py | 11 +- brewman/brewman/routers/batch.py | 14 +- brewman/brewman/routers/batch_integrity.py | 18 +- brewman/brewman/routers/incentive.py | 4 +- brewman/brewman/routers/issue.py | 28 +- brewman/brewman/routers/product.py | 278 ++++++++++-------- brewman/brewman/routers/purchase.py | 83 ++++-- brewman/brewman/routers/purchase_return.py | 60 ++-- brewman/brewman/routers/rate_contract.py | 8 +- brewman/brewman/routers/rebase.py | 7 +- .../brewman/routers/reports/closing_stock.py | 21 +- brewman/brewman/routers/reports/entries.py | 14 +- .../brewman/routers/reports/product_ledger.py | 117 ++++---- .../routers/reports/purchase_entries.py | 19 +- brewman/brewman/routers/reports/purchases.py | 18 +- .../routers/reports/raw_material_cost.py | 24 +- .../brewman/routers/reports/stock_movement.py | 79 +++-- brewman/brewman/routers/reset_stock.py | 142 --------- brewman/brewman/routers/voucher.py | 18 +- brewman/brewman/schemas/batch.py | 5 +- brewman/brewman/schemas/inventory.py | 4 +- brewman/brewman/schemas/product.py | 12 +- brewman/brewman/schemas/product_ledger.py | 2 + brewman/brewman/schemas/rate_contract_item.py | 2 +- brewman/brewman/schemas/settings.py | 25 -- brewman/brewman/schemas/stock_keeping_unit.py | 23 ++ overlord/src/app/app-routing.module.ts | 4 +- overlord/src/app/core/batch.ts | 8 +- overlord/src/app/core/inventory.ts | 4 +- overlord/src/app/core/product.ts | 36 ++- .../src/app/issue/issue-dialog.component.ts | 1 - overlord/src/app/issue/issue.component.html | 2 +- overlord/src/app/issue/issue.component.ts | 7 +- .../app/product-ledger/product-ledger-item.ts | 4 + .../product-ledger.component.html | 12 +- .../product-ledger.component.ts | 4 +- .../product-detail-datasource.ts | 16 + .../product-detail-dialog.component.css | 0 .../product-detail-dialog.component.html | 46 +++ .../product-detail-dialog.component.spec.ts | 26 ++ .../product-detail-dialog.component.ts | 68 +++++ .../product-detail.component.html | 154 +++++++--- .../product-detail.component.ts | 136 +++++++-- .../product-list/product-list-datasource.ts | 9 +- .../product-list/product-list.component.css | 3 + .../product-list/product-list.component.html | 34 ++- .../product-list/product-list.component.ts | 8 +- overlord/src/app/product/product.module.ts | 5 +- overlord/src/app/product/product.service.ts | 5 +- .../purchase-return-dialog.component.ts | 1 - .../purchase-return.component.html | 2 +- .../purchase-return.component.ts | 7 +- .../app/purchase/purchase-dialog.component.ts | 10 +- .../src/app/purchase/purchase.component.html | 2 +- .../src/app/purchase/purchase.component.ts | 15 +- .../rate-contract-detail.component.html | 2 +- .../rate-contract-detail.component.ts | 6 +- .../app/rate-contract/rate-contract-item.ts | 4 +- .../rate-contract-list.component.html | 2 +- .../src/app/settings/settings.component.html | 68 ----- .../src/app/settings/settings.component.ts | 67 +---- overlord/src/app/settings/settings.service.ts | 12 - 71 files changed, 1272 insertions(+), 904 deletions(-) create mode 100644 brewman/alembic/versions/0670868fe171_stock_keeping_units.py create mode 100644 brewman/brewman/models/stock_keeping_unit.py delete mode 100644 brewman/brewman/routers/reset_stock.py create mode 100644 brewman/brewman/schemas/stock_keeping_unit.py create mode 100644 overlord/src/app/product/product-detail/product-detail-datasource.ts create mode 100644 overlord/src/app/product/product-detail/product-detail-dialog.component.css create mode 100644 overlord/src/app/product/product-detail/product-detail-dialog.component.html create mode 100644 overlord/src/app/product/product-detail/product-detail-dialog.component.spec.ts create mode 100644 overlord/src/app/product/product-detail/product-detail-dialog.component.ts diff --git a/brewman/alembic/versions/0670868fe171_stock_keeping_units.py b/brewman/alembic/versions/0670868fe171_stock_keeping_units.py new file mode 100644 index 00000000..4bedf596 --- /dev/null +++ b/brewman/alembic/versions/0670868fe171_stock_keeping_units.py @@ -0,0 +1,160 @@ +"""stock_keeping_units + +Revision ID: 0670868fe171 +Revises: 6fb6c96fd408 +Create Date: 2021-09-25 12:17:58.540829 + +""" +import sqlalchemy as sa + +from alembic import op +from sqlalchemy import Boolean, Numeric, Unicode, column, select, table +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +from sqlalchemy.dialects.postgresql import UUID + + +revision = "0670868fe171" +down_revision = "6fb6c96fd408" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "stock_keeping_units", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False), + sa.Column("units", sa.Unicode(length=255), nullable=False), + sa.Column("fraction", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("fraction_units", sa.Unicode(length=255), nullable=False), + sa.Column("product_yield", sa.Numeric(precision=15, scale=5), nullable=False), + sa.Column("cost_price", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("sale_price", sa.Numeric(precision=15, scale=2), nullable=False), + sa.ForeignKeyConstraint( + ["product_id"], ["products.id"], name=op.f("fk_stock_keeping_units_product_id_products") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_stock_keeping_units")), + sa.UniqueConstraint("product_id", "units", name=op.f("uq_stock_keeping_units_product_id")), + ) + op.create_index( + "unique_true_is_default", + "stock_keeping_units", + ["product_id"], + unique=True, + postgresql_where=sa.text("is_default = true"), + ) + op.drop_constraint("uq_products_name", "products", type_="unique") + op.create_unique_constraint(op.f("uq_products_name"), "products", ["name"]) + + sku = table( + "stock_keeping_units", + column("id", UUID(as_uuid=True)), + column("product_id", UUID(as_uuid=True)), + column("is_default", Boolean), + column("units", Unicode(255)), + column("fraction", Numeric(precision=15, scale=5)), + column("fraction_units", Unicode(255)), + column("product_yield", Numeric(precision=15, scale=5)), + column("cost_price", Numeric(precision=15, scale=2)), + column("sale_price", Numeric(precision=15, scale=2)), + ) + product = table( + "products", + column("id", UUID(as_uuid=True)), + column("units", Unicode(255)), + column("fraction", Numeric(precision=15, scale=5)), + column("fraction_units", Unicode(255)), + column("product_yield", Numeric(precision=15, scale=5)), + column("cost_price", Numeric(precision=15, scale=2)), + column("sale_price", Numeric(precision=15, scale=2)), + ) + + op.execute( + sku.insert().from_select( + [ + sku.c.id, + sku.c.product_id, + sku.c.is_default, + sku.c.units, + sku.c.fraction, + sku.c.fraction_units, + sku.c.product_yield, + sku.c.cost_price, + sku.c.sale_price, + ], + select( + [ + product.c.id, + product.c.id, + True, + product.c.units, + product.c.fraction, + product.c.fraction_units, + product.c.product_yield, + product.c.cost_price, + product.c.sale_price, + ] + ), + ) + ) + + op.drop_column("products", "cost_price") + op.drop_column("products", "units") + op.drop_column("products", "sale_price") + op.drop_column("products", "product_yield") + op.drop_column("products", "fraction_units") + op.drop_column("products", "fraction") + + op.drop_constraint("uq_rate_contract_items_rate_contract_id", "rate_contract_items", type_="unique") + op.drop_constraint("fk_rate_contract_items_product_id_products", "rate_contract_items", type_="foreignkey") + op.alter_column("rate_contract_items", "product_id", new_column_name="sku_id") + op.create_unique_constraint( + op.f("uq_rate_contract_items_rate_contract_id"), "rate_contract_items", ["rate_contract_id", "sku_id"] + ) + op.create_foreign_key( + op.f("fk_rate_contract_items_sku_id_stock_keeping_units"), + "rate_contract_items", + "stock_keeping_units", + ["sku_id"], + ["id"], + ) + + op.drop_constraint("batches_ProductID_fkey", "batches", type_="foreignkey") + op.alter_column("batches", "product_id", new_column_name="sku_id") + op.create_foreign_key( + op.f("fk_batches_sku_id_stock_keeping_units"), "batches", "stock_keeping_units", ["sku_id"], ["id"] + ) + + op.drop_constraint("inventories_ProductID_fkey", "inventories", type_="foreignkey") + op.drop_column("inventories", "product_id") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "products", sa.Column("fraction", sa.NUMERIC(precision=15, scale=5), autoincrement=False, nullable=False) + ) + op.add_column("products", sa.Column("fraction_units", sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + op.add_column( + "products", sa.Column("product_yield", sa.NUMERIC(precision=15, scale=5), autoincrement=False, nullable=False) + ) + op.add_column( + "products", sa.Column("sale_price", sa.NUMERIC(precision=15, scale=2), autoincrement=False, nullable=False) + ) + op.add_column("products", sa.Column("units", sa.VARCHAR(length=255), autoincrement=False, nullable=False)) + op.add_column( + "products", sa.Column("cost_price", sa.NUMERIC(precision=15, scale=2), autoincrement=False, nullable=False) + ) + op.drop_constraint(op.f("uq_products_name"), "products", type_="unique") + op.create_unique_constraint("uq_products_name", "products", ["name", "units"]) + op.drop_index( + "unique_true_is_default", table_name="stock_keeping_units", postgresql_where=sa.text("is_default = true") + ) + op.drop_table("stock_keeping_units") + # ### end Alembic commands ### diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index 521edec3..39f68ca2 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -24,6 +24,7 @@ from ..models.recipe import Recipe # noqa: F401 from ..models.recipe_item import RecipeItem # noqa: F401 from ..models.role import Role # noqa: F401 from ..models.role_permission import role_permission # noqa: F401 +from ..models.stock_keeping_unit import StockKeepingUnit # noqa: F401 from ..models.user import User # noqa: F401 from ..models.user_role import user_role # noqa: F401 from ..models.voucher import Voucher # noqa: F401 diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index b6723dbb..2a7f6729 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -37,7 +37,6 @@ from .routers import ( rate_contract, rebase, recipe, - reset_stock, role, user, voucher, @@ -125,7 +124,6 @@ app.include_router(lock_information.router, prefix="/api/lock-information", tags app.include_router(maintenance.router, prefix="/api/maintenance", tags=["settings"]) app.include_router(db_integrity.router, prefix="/api/db-integrity", tags=["management"]) -app.include_router(reset_stock.router, prefix="/api/reset-stock", tags=["management"]) app.include_router(rebase.router, prefix="/api/rebase", tags=["management"]) diff --git a/brewman/brewman/models/account_base.py b/brewman/brewman/models/account_base.py index 2217b4e9..2da8a8d6 100644 --- a/brewman/brewman/models/account_base.py +++ b/brewman/brewman/models/account_base.py @@ -31,6 +31,7 @@ class AccountBase(Base): journals = relationship("Journal", back_populates="account") cost_centre = relationship("CostCentre", back_populates="accounts") + products = relationship("Product", back_populates="account") rate_contracts = relationship("RateContract", back_populates="vendor") diff --git a/brewman/brewman/models/batch.py b/brewman/brewman/models/batch.py index 8f049d38..fb7600b8 100644 --- a/brewman/brewman/models/batch.py +++ b/brewman/brewman/models/batch.py @@ -4,10 +4,11 @@ from datetime import date from sqlalchemy import Column, Date, ForeignKey, Numeric, select from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Session, relationship +from sqlalchemy.orm import Session, contains_eager, relationship from .meta import Base from .product import Product +from .stock_keeping_unit import StockKeepingUnit class Batch(Base): @@ -15,42 +16,48 @@ class Batch(Base): id = Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column("name", Date, nullable=False) - product_id = Column("product_id", UUID(as_uuid=True), ForeignKey("products.id"), nullable=False) + sku_id = Column("sku_id", UUID(as_uuid=True), ForeignKey("stock_keeping_units.id"), nullable=False) quantity_remaining = Column("quantity_remaining", Numeric(precision=15, scale=2), nullable=False) rate = Column("rate", Numeric(precision=15, scale=2), nullable=False) tax = Column("tax", Numeric(precision=15, scale=5), nullable=False) discount = Column("discount", Numeric(precision=15, scale=5), nullable=False) inventories = relationship("Inventory", back_populates="batch") - product = relationship("Product", back_populates="batches") + sku = relationship("StockKeepingUnit", back_populates="batches") def __init__( self, name=None, - product_id=None, + sku_id=None, quantity_remaining=None, rate=None, tax=None, discount=None, - product=None, + sku=None, ): self.name = name - self.product_id = product_id + self.sku_id = sku_id self.quantity_remaining = quantity_remaining self.rate = rate self.tax = tax self.discount = discount - if product is None: - self.product_id = product_id + if sku is None: + self.sku_id = sku_id else: - self.product = product + self.sku_id = sku.id + self.sku = sku def amount(self): 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): - query = select(cls).join(cls.product) + query = ( + select(cls) + .join(cls.sku) + .join(StockKeepingUnit.product) + .options(contains_eager(cls.sku).contains_eager(StockKeepingUnit.product)) + ) if not include_nil: query = query.where(cls.quantity_remaining > 0) if date_ is not None: diff --git a/brewman/brewman/models/inventory.py b/brewman/brewman/models/inventory.py index fc9707fc..588f2384 100644 --- a/brewman/brewman/models/inventory.py +++ b/brewman/brewman/models/inventory.py @@ -19,7 +19,6 @@ class Inventory(Base): nullable=False, index=True, ) - product_id = Column("product_id", UUID(as_uuid=True), ForeignKey("products.id"), nullable=False) batch_id = Column("batch_id", UUID(as_uuid=True), ForeignKey("batches.id"), nullable=False) quantity = Column("quantity", Numeric(precision=15, scale=2), nullable=False) rate = Column("rate", Numeric(precision=15, scale=2), nullable=False) @@ -27,14 +26,12 @@ class Inventory(Base): discount = Column("discount", Numeric(precision=15, scale=5), nullable=False) voucher = relationship("Voucher", back_populates="inventories") - product = relationship("Product", back_populates="inventories") batch = relationship("Batch", back_populates="inventories") def __init__( self, id_=None, voucher_id=None, - product_id=None, batch_id=None, quantity=None, rate=None, @@ -45,11 +42,6 @@ class Inventory(Base): ): self.id = id_ self.voucher_id = voucher_id - if product is None: - self.product_id = product_id - else: - self.product = product - self.product_id = product.id if batch is None: self.batch_id = batch_id else: @@ -62,3 +54,7 @@ class Inventory(Base): @hybrid_property def amount(self): return self.quantity * self.rate * (1 + self.tax) * (1 - self.discount) + + @amount.expression + def amount(cls): + return cls.quantity * cls.rate * (1 + cls.tax) * (1 - cls.discount) diff --git a/brewman/brewman/models/product.py b/brewman/brewman/models/product.py index 1d4f8066..1426846f 100644 --- a/brewman/brewman/models/product.py +++ b/brewman/brewman/models/product.py @@ -1,35 +1,19 @@ import uuid -from sqlalchemy import ( - Boolean, - Column, - ForeignKey, - Integer, - Numeric, - Unicode, - UniqueConstraint, - case, - func, - select, -) +from sqlalchemy import Boolean, Column, ForeignKey, Integer, Unicode, desc, func, select from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Session, relationship from .meta import Base +from .stock_keeping_unit import StockKeepingUnit class Product(Base): __tablename__ = "products" - __table_args__ = (UniqueConstraint("name", "units"),) 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) - 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) + name = Column("name", Unicode(255), nullable=False, unique=True) product_group_id = Column( "product_group_id", UUID(as_uuid=True), @@ -37,16 +21,13 @@ class Product(Base): nullable=False, ) account_id = Column("account_id", UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=False) - price = Column("cost_price", Numeric(precision=15, scale=2), nullable=False) - sale_price = Column("sale_price", Numeric(precision=15, scale=2), nullable=False) is_active = Column("is_active", Boolean, nullable=False) is_fixture = Column("is_fixture", Boolean, nullable=False) 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)) product_group = relationship("ProductGroup", back_populates="products") - batches = relationship("Batch", back_populates="product") - inventories = relationship("Inventory", back_populates="product") recipes = relationship("Recipe", back_populates="product") account = relationship("Account", primaryjoin="Account.id==Product.account_id", back_populates="products") @@ -54,14 +35,8 @@ class Product(Base): self, code=None, name=None, - units=None, - fraction=None, - fraction_units=None, - product_yield=None, product_group_id=None, account_id=None, - price=None, - sale_price=None, is_active=None, is_purchased=None, is_sold=None, @@ -70,55 +45,19 @@ class Product(Base): ): self.code = code self.name = name - self.units = units - self.fraction = fraction - self.fraction_units = fraction_units - self.product_yield = product_yield self.product_group_id = product_group_id self.account_id = account_id - self.price = price - self.sale_price = sale_price self.is_active = is_active self.is_purchased = is_purchased self.is_sold = is_sold self.id = id_ self.is_fixture = is_fixture - @hybrid_property - def full_name(self): - return f"{self.name} ({self.units})" if self.units else self.name - - @full_name.expression - def full_name(cls): - return cls.name + case([(cls.units != "", " (" + cls.units + ")")], else_="") - 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 - def can_delete(self, advanced_delete: bool): - if self.is_fixture: - return False, f"{self.name} is a fixture and cannot be edited or deleted." - if self.is_active: - return False, "Product is active" - if len(self.inventories) > 0 and not advanced_delete: - return False, "Product has entries" - return True, "" - - @classmethod - def query(cls, q, is_purchased: bool = None, active: bool = None, db: Session = None): - query_ = select(cls) - if active is not None: - query_ = query_.filter(cls.is_active == active) - if is_purchased is not None: - query_ = query_.filter(cls.is_purchased == is_purchased) - if q is not None: - for item in q.split(): - if item.strip() != "": - query_ = query_.filter(cls.name.ilike(f"%{item}%")) - return db.execute(query_).scalars().all() - @classmethod def suspense(cls): return uuid.UUID("aa79a643-9ddc-4790-ac7f-a41f9efb4c15") diff --git a/brewman/brewman/models/rate_contract_item.py b/brewman/brewman/models/rate_contract_item.py index 3b57cc59..45d6d72e 100644 --- a/brewman/brewman/models/rate_contract_item.py +++ b/brewman/brewman/models/rate_contract_item.py @@ -9,7 +9,7 @@ from .meta import Base class RateContractItem(Base): __tablename__ = "rate_contract_items" - __table_args__ = (UniqueConstraint("rate_contract_id", "product_id"),) + __table_args__ = (UniqueConstraint("rate_contract_id", "sku_id"),) id = Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) rate_contract_id = Column( @@ -19,20 +19,20 @@ class RateContractItem(Base): nullable=False, index=True, ) - product_id = Column("product_id", UUID(as_uuid=True), ForeignKey("products.id"), nullable=False) + sku_id = Column("sku_id", UUID(as_uuid=True), ForeignKey("stock_keeping_units.id"), nullable=False) price = Column("cost_price", Numeric(precision=15, scale=2), nullable=False) rate_contract = relationship("RateContract", back_populates="items") - product = relationship("Product") + sku = relationship("StockKeepingUnit") def __init__( self, rate_contract_id=None, - product_id=None, + sku_id=None, price=None, id_=None, ): self.rate_contract_id = rate_contract_id - self.product_id = product_id + self.sku_id = sku_id self.price = price self.id = id_ diff --git a/brewman/brewman/models/stock_keeping_unit.py b/brewman/brewman/models/stock_keeping_unit.py new file mode 100644 index 00000000..20b82c19 --- /dev/null +++ b/brewman/brewman/models/stock_keeping_unit.py @@ -0,0 +1,68 @@ +import uuid + +from sqlalchemy import ( + Boolean, + Column, + ForeignKey, + Index, + Numeric, + Unicode, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +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"), + ), + ) + + 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) + sale_price = Column("sale_price", Numeric(precision=15, scale=2), nullable=False) + + product = relationship("Product", back_populates="skus") + batches = relationship("Batch", back_populates="sku") + + def __init__( + self, + product_id=None, + is_default=None, + units=None, + fraction=None, + fraction_units=None, + product_yield=None, + 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.sale_price = sale_price + self.id = id_ + if product is not None: + self.product = product diff --git a/brewman/brewman/models/validations.py b/brewman/brewman/models/validations.py index af2d288a..bb8b9bdc 100644 --- a/brewman/brewman/models/validations.py +++ b/brewman/brewman/models/validations.py @@ -1,8 +1,3 @@ -from typing import Union - -import brewman.schemas.input as schema_in -import brewman.schemas.voucher as schema - from fastapi import HTTPException, status from .voucher import Voucher @@ -32,15 +27,15 @@ def check_journals_are_valid(voucher: Voucher): ) -def check_inventories_are_valid(voucher: Union[Voucher, schema.Voucher, schema_in.JournalIn]): +def check_inventories_are_valid(voucher: Voucher): if len(voucher.inventories) < 1: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Not enough inventories", ) - product_set = set(x.product_id for x in voucher.inventories) + product_set = set(x.batch.sku_id for x in voucher.inventories) if len(voucher.inventories) != len(product_set): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Duplicate journals are not allowed", + detail="Duplicate inventories are not allowed", ) diff --git a/brewman/brewman/routers/batch.py b/brewman/brewman/routers/batch.py index 3cc2599f..2628c12f 100644 --- a/brewman/brewman/routers/batch.py +++ b/brewman/brewman/routers/batch.py @@ -14,16 +14,15 @@ router = APIRouter() @router.get("") def batch_term( q: str, - c: int = None, d: str = None, current_user: UserToken = Depends(get_user), ): date = None if not d else datetime.datetime.strptime(d, "%d-%b-%Y") list_ = [] with SessionFuture() as db: - for index, item in enumerate(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.product.name} ({item.product.units}) {item.quantity_remaining:.2f}@" + f"{item.sku.product.name} ({item.sku.units}) {item.quantity_remaining:.2f}@" f"{item.rate:.2f} from {item.name.strftime('%d-%b-%Y')}" ) list_.append( @@ -34,13 +33,10 @@ def batch_term( "rate": round(item.rate, 2), "tax": round(item.tax, 5), "discount": round(item.discount, 5), - "product": { - "id": item.product.id, - "name": item.product.name, - "units": item.product.units, + "sku": { + "id": item.sku.id, + "name": f"{item.sku.product.name} ({item.sku.units})", }, } ) - if c is not None and index == c - 1: - break return list_ diff --git a/brewman/brewman/routers/batch_integrity.py b/brewman/brewman/routers/batch_integrity.py index d93c62c1..61dc0d7e 100644 --- a/brewman/brewman/routers/batch_integrity.py +++ b/brewman/brewman/routers/batch_integrity.py @@ -15,6 +15,7 @@ from ..models.cost_centre import CostCentre from ..models.inventory import Inventory from ..models.journal import Journal from ..models.product import Product +from ..models.stock_keeping_unit import StockKeepingUnit from ..models.voucher import Voucher from ..models.voucher_type import VoucherType from ..schemas.user import UserToken @@ -39,8 +40,9 @@ def post_check_batch_integrity( def negative_batches(db: Session) -> List[schemas.BatchIntegrity]: inv_sum = func.sum(Inventory.quantity * Journal.debit).label("quantity") list_ = db.execute( - select(Batch, Product.full_name, inv_sum) - .join(Batch.product) + select(Batch, Product.name, inv_sum) + .join(Batch.sku) + .join(StockKeepingUnit.product) .join(Batch.inventories) .join(Inventory.voucher) .join(Voucher.journals) @@ -55,7 +57,7 @@ def negative_batches(db: Session) -> List[schemas.BatchIntegrity]: ), Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(Batch, Product.full_name) + .group_by(Batch, Product.name) .having(Batch.quantity_remaining != inv_sum) ).all() @@ -112,11 +114,15 @@ def batch_dates(db: Session) -> List[schemas.BatchIntegrity]: list_ = ( db.execute( select(Batch) - .join(Batch.product) + .join(Batch.sku) + .join(StockKeepingUnit.product) .join(Batch.inventories) .join(Inventory.voucher) .where(Voucher.date < Batch.name) - .options(contains_eager(Batch.product), contains_eager(Batch.inventories).contains_eager(Inventory.voucher)) + .options( + contains_eager(Batch.sku).contains_eager(StockKeepingUnit.product), + contains_eager(Batch.inventories).contains_eager(Inventory.voucher), + ) ) .unique() .scalars() @@ -128,7 +134,7 @@ def batch_dates(db: Session) -> List[schemas.BatchIntegrity]: issue.append( schemas.BatchIntegrity( id=batch.id, - product=batch.product.full_name, + product=batch.sku.product.name, date=batch.name, showing=batch.quantity_remaining, actual=0, diff --git a/brewman/brewman/routers/incentive.py b/brewman/brewman/routers/incentive.py index 10e2c6ce..531ccf7a 100644 --- a/brewman/brewman/routers/incentive.py +++ b/brewman/brewman/routers/incentive.py @@ -260,7 +260,7 @@ def get_employees( def balance(date_: date, voucher_id: Optional[uuid.UUID], db: Session): - amount = db.execute( + amount = ( select(func.sum(Journal.amount * Journal.debit)) .join(Journal.voucher) .where( @@ -277,7 +277,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 = amount.scalar() + result: Decimal = db.execute(amount).scalar_one_or_none() return 0 if result is None else result * -1 diff --git a/brewman/brewman/routers/issue.py b/brewman/brewman/routers/issue.py index dd2f9d0f..601982c2 100644 --- a/brewman/brewman/routers/issue.py +++ b/brewman/brewman/routers/issue.py @@ -21,6 +21,7 @@ from ..models.cost_centre import CostCentre from ..models.inventory import Inventory from ..models.journal import Journal from ..models.product import Product +from ..models.stock_keeping_unit import StockKeepingUnit from ..models.validations import check_inventories_are_valid, check_journals_are_valid from ..models.voucher import Voucher from ..models.voucher_type import VoucherType @@ -67,7 +68,11 @@ def save_route( def save(data: schema_in.IssueIn, user: UserToken, db: Session) -> (Voucher, Optional[bool]): - product_accounts = select(Product.account_id).where(Product.id.in_([i.product.id_ for i in data.inventories])) + product_accounts = ( + select(Product.account_id) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + ) account_types = ( db.execute(select(distinct(AccountBase.type)).where(AccountBase.id.in_(product_accounts))).scalars().all() ) @@ -111,12 +116,13 @@ def save_inventories( if batch_consumed and item.quantity > batch.quantity_remaining: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Quantity available is {batch.quantity_remaining} only", + detail=f"{batch.sku.product.name} ({batch.sku.units}) stock is {batch.quantity_remaining}", ) if batch.name > voucher.date: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Batch of {batch.product.name} was purchased after the issue date", + detail=f"{batch.sku.product.name} ({batch.sku.units}) " + f"was purchased on {batch.name.strftime('%d-%b-%Y')}", ) if batch_consumed is None: pass @@ -127,7 +133,6 @@ def save_inventories( item = Inventory( id_=item.id_, - product=batch.product, quantity=item.quantity, rate=batch.rate, tax=batch.tax, @@ -195,7 +200,11 @@ def update_route( def update_voucher(id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session) -> (Voucher, Optional[bool]): voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() - product_accounts = select(Product.account_id).where(Product.id.in_([i.product.id_ for i in data.inventories])) + product_accounts = ( + select(Product.account_id) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + ) account_types = ( db.execute( select(distinct(AccountBase.type)).where( @@ -266,7 +275,7 @@ def update_inventories( amount: Decimal = Decimal(0) for it in range(len(voucher.inventories), 0, -1): item = voucher.inventories[it - 1] - batch = db.execute(select(Batch).where(Batch.id == item.batch_id)).scalar_one() + batch: Batch = db.execute(select(Batch).where(Batch.id == item.batch_id)).scalar_one() batch_quantity = get_batch_quantity(item.batch_id, voucher.id, db) index = next((idx for (idx, d) in enumerate(inventories) if d.id_ == item.id), None) if index is not None: @@ -274,12 +283,13 @@ def update_inventories( if batch_consumed and new_inventory.quantity > batch_quantity: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Maximum quantity available for {item.product.full_name} is {batch_quantity}", + detail=f"Maximum quantity available for " + f"{batch.sku.product.name} ({batch.sku.units}) is {batch_quantity}", ) if item.batch.name > voucher.date: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Batch of {item.product.name} was purchased after the issue date", + detail=f"Batch of {batch.sku.product.name} ({batch.sku.units}) was purchased after the issue date", ) if batch_consumed is None: @@ -303,7 +313,7 @@ def update_inventories( if batch_quantity < item.quantity: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Product {item.product.name} cannot be removed," + detail=f"Product {batch.sku.product.name} ({batch.sku.units}) cannot be removed," f" minimum quantity is {batch_quantity}", ) item.batch.quantity_remaining = batch_quantity diff --git a/brewman/brewman/routers/product.py b/brewman/brewman/routers/product.py index ed849369..e320d6e0 100644 --- a/brewman/brewman/routers/product.py +++ b/brewman/brewman/routers/product.py @@ -1,25 +1,24 @@ import uuid from datetime import datetime +from decimal import Decimal from typing import List, Optional import brewman.schemas.product as schemas from fastapi import APIRouter, Depends, HTTPException, Security, status -from sqlalchemy import desc, select +from sqlalchemy import delete, desc, func, or_, select from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, contains_eager from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.account import Account from ..models.batch import Batch -from ..models.inventory import Inventory from ..models.product import Product from ..models.rate_contract import RateContract from ..models.rate_contract_item import RateContractItem -from ..models.voucher import Voucher -from ..models.voucher_type import VoucherType +from ..models.stock_keeping_unit import StockKeepingUnit from ..schemas.user import UserToken @@ -35,18 +34,34 @@ def save( with SessionFuture() as db: item = Product( name=data.name, - units=data.units, - fraction=round(data.fraction, 5), - fraction_units=data.fraction_units, - product_yield=round(data.product_yield, 5), product_group_id=data.product_group.id_, account_id=Account.all_purchases(), - price=round(data.price, 2), - sale_price=round(data.sale_price, 2), 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", + ) + 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), + sale_price=round(sku.sale_price, 2), + product=item, + ) + ) db.commit() return product_info(item) except SQLAlchemyError as e: @@ -71,17 +86,59 @@ def update_route( detail=f"{item.name} is a fixture and cannot be edited or deleted.", ) item.name = data.name - item.units = data.units - item.fraction = round(data.fraction, 5) - item.fraction_units = data.fraction_units - item.product_yield = round(data.product_yield, 5) item.product_group_id = data.product_group.id_ item.account_id = Account.all_purchases() - item.price = round(data.price, 2) - item.sale_price = round(data.sale_price, 2) 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.sale_price = round(new_sku.sale_price, 2) + else: + count: Decimal = db.execute(select(func.count()).where(Batch.sku_id == sku.id)).scalar_one() + if count > 0: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"SKU '{sku.units}' has entries and cannot be deleted.", + ) + item.skus.remove(sku) + 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), + 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: @@ -98,17 +155,22 @@ def delete_route( ) -> schemas.ProductBlank: with SessionFuture() as db: item: Product = db.execute(select(Product).where(Product.id == id_)).scalar_one() - can_delete, reason = item.can_delete("advanced-delete" in user.permissions) - - if can_delete: - delete_with_data(item, db) - db.commit() - return product_blank() - else: + if item.is_fixture: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Cannot delete product because {reason}", + detail=f"{item.name} is a fixture and cannot be edited or deleted.", ) + count: Decimal = db.execute( + select(func.count()).join(StockKeepingUnit.batches).where(StockKeepingUnit.product_id == id_) + ).scalar_one() + if count > 0: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Product has entries and cannot be deleted." + ) + db.execute(delete(StockKeepingUnit).where(StockKeepingUnit.product_id == id_)) + db.execute(delete(Product).where(Product.id == id_)) + db.commit() + return product_blank() @router.get("", response_model=schemas.ProductBlank) @@ -136,55 +198,56 @@ def show_list(user: UserToken = Depends(get_user)) -> List[schemas.Product]: @router.get("/query") async def show_term( - q: str = None, - a: bool = None, - c: int = None, - p: bool = None, - e: bool = False, - v: Optional[uuid.UUID] = None, - d: Optional[str] = None, + 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), ): - count = c - extended = e list_ = [] with SessionFuture() as db: - for index, item in enumerate(Product.query(q, p, a, db)): - rc_price = None - if v is not None and d is not None: - date_ = datetime.strptime(d, "%d-%b-%Y") - contracts = select(RateContract.id).where( - RateContract.vendor_id == v, RateContract.valid_from <= date_, RateContract.valid_till >= date_ - ) - rc_price = db.execute( - select(RateContractItem.price).where( - RateContractItem.product_id == item.id, RateContractItem.rate_contract_id.in_(contracts) + query_ = select(Product).join(Product.skus).options(contains_eager(Product.skus)) + 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( + or_(Product.name.ilike(f"%{item}%"), StockKeepingUnit.units.ilike(f"%{item}%")) ) - ).scalar_one_or_none() - list_.append( - { - "id": item.id, - "name": item.name, - "price": item.price if rc_price is None else rc_price, - "units": item.units, - "fraction": item.fraction, - "fractionUnits": item.fraction_units, - "productYield": item.product_yield, - "isSold": item.is_sold, - "salePrice": item.sale_price, - "isRateContracted": False if rc_price is None else True, - } - if extended - else { - "id": item.id, - "name": item.full_name, - "price": item.price if rc_price is None else rc_price, - "isRateContracted": False if rc_price is None else True, - } - ) - if count is not None and index == count - 1: - break - return sorted(list_, key=lambda k: k["name"]) + 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: + 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, + } + ) + return list_ @router.get("/{id_}", response_model=schemas.Product) @@ -202,12 +265,19 @@ def product_info(product: Product) -> schemas.Product: id=product.id, code=product.code, name=product.name, - units=product.units, - fraction=product.fraction, - fractionUnits=product.fraction_units, - productYield=product.product_yield, - price=product.price, - salePrice=product.sale_price, + 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, + salePrice=sku.sale_price, + ) + for sku in product.skus + ], isActive=product.is_active, isFixture=product.is_fixture, isPurchased=product.is_purchased, @@ -220,12 +290,7 @@ def product_info(product: Product) -> schemas.Product: def product_blank() -> schemas.ProductBlank: return schemas.ProductBlank( name="", - units="", - fraction=1, - fractionUnits="", - productYield=1, - price=0, - salePrice=0, + skus=[], isActive=True, isPurchased=True, isSold=False, @@ -233,43 +298,16 @@ def product_blank() -> schemas.ProductBlank: ) -def delete_with_data(product: Product, db: Session) -> None: - suspense_product = db.execute(select(Product).where(Product.id == Product.suspense())).scalar_one() - suspense_batch = db.execute(select(Batch).where(Batch.id == Batch.suspense())).scalar_one() - query = ( - db.execute( - select(Voucher) - .options(joinedload(Voucher.inventories, innerjoin=True).joinedload(Inventory.product, innerjoin=True)) - .where(Voucher.inventories.any(Inventory.product_id == product.id)) - ) - .scalars() - .all() +def get_rc_price(id_: uuid.UUID, d: Optional[str], v: Optional[uuid.UUID], db: Session) -> Optional[Decimal]: + if d is None or v is None: + return None + date_ = datetime.strptime(d, "%d-%b-%Y") + contracts = select(RateContract.id).where( + RateContract.vendor_id == v, RateContract.valid_from <= date_, RateContract.valid_till >= date_ ) - - for voucher in query: - others, sus_inv, prod_inv = False, None, None - for inventory in voucher.inventories: - if inventory.product_id == product.id: - prod_inv = inventory - elif inventory.product_id == Product.suspense(): - sus_inv = inventory - else: - others = True - if not others and voucher.type == VoucherType.by_id("Issue"): - db.delete(voucher) - else: - if sus_inv is None: - prod_inv.product = suspense_product - prod_inv.quantity = prod_inv.amount - prod_inv.rate = 1 - prod_inv.tax = 0 - prod_inv.discount = 0 - prod_inv.batch = suspense_batch - voucher.narration += f"\nSuspense \u20B9{prod_inv.amount:,.2f} is {product.name}" - else: - sus_inv.quantity += prod_inv.amount - db.delete(prod_inv) - voucher.narration += f"\nDeleted \u20B9{prod_inv.amount:,.2f} of {product.name}" - for batch in product.batches: - db.delete(batch) - db.delete(product) + rc_price = db.execute( + select(RateContractItem.price).where( + RateContractItem.sku_id == id_, RateContractItem.rate_contract_id.in_(contracts) + ) + ).scalar_one_or_none() + return rc_price diff --git a/brewman/brewman/routers/purchase.py b/brewman/brewman/routers/purchase.py index 77aeb724..2f8f3978 100644 --- a/brewman/brewman/routers/purchase.py +++ b/brewman/brewman/routers/purchase.py @@ -22,6 +22,7 @@ from ..models.journal import Journal 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 ..models.validations import check_inventories_are_valid, check_journals_are_valid from ..models.voucher import Voucher from ..models.voucher_type import VoucherType @@ -69,7 +70,11 @@ def save_route( def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: - product_accounts = select(Product.account_id).where(Product.id.in_([i.product.id_ for i in data.inventories])) + product_accounts = ( + select(Product.account_id) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + ) account_types = ( db.execute( select(distinct(AccountBase.type)).where( @@ -101,8 +106,10 @@ def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: def save_inventories(voucher: Voucher, vendor_id: uuid.UUID, inventories: List[InventorySchema], db: Session): for item in inventories: - product: Product = db.execute(select(Product).where(Product.id == item.product.id_)).scalar_one() - rc_price = rate_contract_price(product.id, vendor_id, voucher.date, db) + sku: StockKeepingUnit = db.execute( + select(StockKeepingUnit).where(StockKeepingUnit.id == item.batch.sku.id_) + ).scalar_one() + rc_price = rate_contract_price(sku.id, vendor_id, voucher.date, db) if rc_price is not None and rc_price != item.rate: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -113,7 +120,7 @@ def save_inventories(voucher: Voucher, vendor_id: uuid.UUID, inventories: List[I item.discount = 0 batch = Batch( name=voucher.date, - product=product, + sku=sku, quantity_remaining=item.quantity, rate=item.rate, tax=item.tax, @@ -122,14 +129,13 @@ def save_inventories(voucher: Voucher, vendor_id: uuid.UUID, inventories: List[I db.add(batch) inventory = Inventory( id_=item.id_, - product=product, batch=batch, quantity=item.quantity, rate=item.rate, tax=item.tax, discount=item.discount, ) - product.price = item.rate + sku.price = item.rate voucher.inventories.append(inventory) db.add(inventory) @@ -139,15 +145,20 @@ def save_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session): journals = {} amount = 0 for item in voucher.inventories: - account = item.product.account + account_id, cc_id = db.execute( + select(AccountBase.id, AccountBase.cost_centre_id) + .join(AccountBase.products) + .join(Product.skus) + .where(StockKeepingUnit.id == item.batch.sku.id) + ).one() amount += round(item.amount, 2) - if account.id in journals: - journals[account.id].amount += round(item.amount, 2) + if account_id in journals: + journals[account_id].amount += round(item.amount, 2) else: - journals[account.id] = Journal( + journals[account_id] = Journal( debit=1, - cost_centre_id=account.cost_centre_id, - account_id=account.id, + cost_centre_id=cc_id, + account_id=account_id, amount=round(item.amount, 2), ) journals[vendor.id] = Journal( @@ -190,7 +201,11 @@ def update_route( def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() - product_accounts = select(Product.account_id).where(Product.id.in_([i.product.id_ for i in data.inventories])) + product_accounts = ( + select(Product.account_id) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + ) account_types = ( db.execute( select(distinct(AccountBase.type)).where( @@ -220,9 +235,9 @@ def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, return voucher -def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, new_inventories: List[InventorySchema], db: Session): - old_set = set([(i.id, i.product_id) for i in voucher.inventories]) - new_set = set([(i.id_, i.product.id_) for i in new_inventories if i.id_ is not None]) +def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, inventories: List[InventorySchema], db: Session): + old_set = set([(i.id, i.batch.sku_id) for i in voucher.inventories]) + new_set = set([(i.id_, i.batch.sku.id_) for i in inventories if i.id_ is not None]) if len(new_set - old_set): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -231,11 +246,13 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, new_inventories: Li for it in range(len(voucher.inventories), 0, -1): item = voucher.inventories[it - 1] quantity_consumed = -1 * get_batch_quantity(item.batch_id, voucher.id, db) - index = next((idx for (idx, d) in enumerate(new_inventories) if d.id_ == item.id), None) + index = next((idx for (idx, d) in enumerate(inventories) if d.id_ == item.id), None) if index is not None: - new_inventory = new_inventories.pop(index) - product = db.execute(select(Product).where(Product.id == new_inventory.product.id_)).scalar_one() - rc_price = rate_contract_price(product.id, vendor_id, voucher.date, db) + new_inventory = inventories.pop(index) + sku: StockKeepingUnit = db.execute( + select(StockKeepingUnit).where(StockKeepingUnit.id == item.batch.sku.id) + ).scalar_one() + rc_price = rate_contract_price(sku.id, vendor_id, voucher.date, db) if batch_has_older_vouchers(item.batch_id, voucher.date, voucher.id, db): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -263,7 +280,7 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, new_inventories: Li item.batch.discount = new_inventory.discount item.tax = new_inventory.tax item.batch.tax = new_inventory.tax - product.price = new_inventory.rate + sku.price = new_inventory.rate db.flush() fix_single_batch_prices(item.batch_id, db) else: @@ -279,7 +296,7 @@ def update_inventory(voucher: Voucher, vendor_id: uuid.UUID, new_inventories: Li db.delete(item.batch) db.delete(item) voucher.inventories.remove(item) - save_inventories(voucher, vendor_id, new_inventories, db) + save_inventories(voucher, vendor_id, inventories, db) def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session): @@ -287,16 +304,20 @@ def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session): journals = {} amount = 0 for item in voucher.inventories: - product = db.execute(select(Product).where(Product.id == item.product_id)).scalar_one() - account = product.account + account_id, cc_id = db.execute( + select(AccountBase.id, AccountBase.cost_centre_id) + .join(AccountBase.products) + .join(Product.skus) + .where(StockKeepingUnit.id == item.batch.sku.id) + ).one() amount += item.amount - if account.id in journals: - journals[account.id].amount += item.amount + if account_id in journals: + journals[account_id].amount += item.amount else: - journals[account.id] = Journal( + journals[account_id] = Journal( debit=1, - cost_centre_id=account.cost_centre_id, - account_id=account.id, + cost_centre_id=cc_id, + account_id=account_id, amount=item.amount, ) journals[vendor.id] = Journal( @@ -347,13 +368,13 @@ def show_blank( return blank_voucher(additional_info, db) -def rate_contract_price(product_id: uuid.UUID, vendor_id: uuid.UUID, date_: date, db: Session) -> Optional[Decimal]: +def rate_contract_price(id_: uuid.UUID, vendor_id: uuid.UUID, date_: date, db: Session) -> Optional[Decimal]: contracts = select(RateContract.id).where( RateContract.vendor_id == vendor_id, RateContract.valid_from <= date_, RateContract.valid_till >= date_ ) return db.execute( select(RateContractItem.price).where( - RateContractItem.product_id == product_id, RateContractItem.rate_contract_id.in_(contracts) + RateContractItem.id == id_, RateContractItem.rate_contract_id.in_(contracts) ) ).scalar_one_or_none() diff --git a/brewman/brewman/routers/purchase_return.py b/brewman/brewman/routers/purchase_return.py index a5a3e86e..cd6feae4 100644 --- a/brewman/brewman/routers/purchase_return.py +++ b/brewman/brewman/routers/purchase_return.py @@ -19,6 +19,7 @@ from ..models.batch import Batch from ..models.inventory import Inventory from ..models.journal import Journal from ..models.product import Product +from ..models.stock_keeping_unit import StockKeepingUnit from ..models.validations import check_inventories_are_valid, check_journals_are_valid from ..models.voucher import Voucher from ..models.voucher_type import VoucherType @@ -65,7 +66,11 @@ def save_route( def save(data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: - product_accounts = select(Product.account_id).where(Product.id.in_([i.product.id_ for i in data.inventories])) + product_accounts = ( + select(Product.account_id) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + ) account_types = ( db.execute( select(distinct(AccountBase.type)).where( @@ -102,18 +107,18 @@ def save_inventories(voucher: Voucher, inventories: List[InventorySchema], db: S if item.quantity > batch.quantity_remaining: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Maximum quantity is {batch.quantity_remaining}.", + detail=f"{batch.sku.product.name} ({batch.sku.units}) stock is {batch.quantity_remaining}", ) if batch.name > voucher.date: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Return date cannot be before {batch.product.name.strftime('%d-%b-%Y')}", + detail=f"{batch.sku.product.name} ({batch.sku.units}) " + f"was purchased on {batch.name.strftime('%d-%b-%Y')}", ) batch.quantity_remaining -= item.quantity item = Inventory( - product=batch.product, quantity=item.quantity, rate=batch.rate, tax=batch.tax, @@ -129,15 +134,20 @@ def save_journals(voucher: Voucher, ven: schema_in.AccountLink, db: Session): journals = {} amount = 0 for item in voucher.inventories: - account = item.product.account + account_id, cc_id = db.execute( + select(AccountBase.id, AccountBase.cost_centre_id) + .join(AccountBase.products) + .join(Product.skus) + .where(StockKeepingUnit.id == item.batch.sku.id) + ).one() amount += round(item.amount, 2) - if account.id in journals: - journals[account.id].amount += round(item.amount, 2) + if account_id in journals: + journals[account_id].amount += round(item.amount, 2) else: - journals[account.id] = Journal( + journals[account_id] = Journal( debit=-1, - cost_centre_id=account.cost_centre_id, - account_id=account.id, + cost_centre_id=cc_id, + account_id=account_id, amount=round(item.amount, 2), ) journals[vendor.id] = Journal( @@ -184,7 +194,11 @@ def update_route( def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, db: Session) -> Voucher: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() - product_accounts = select(Product.account_id).where(Product.id.in_([i.product.id_ for i in data.inventories])) + product_accounts = ( + select(Product.account_id) + .join(Product.skus) + .where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories])) + ) account_types = ( db.execute( select(distinct(AccountBase.type)).where( @@ -214,8 +228,8 @@ def update_voucher(id_: uuid.UUID, data: schema_in.PurchaseIn, user: UserToken, def update_inventory(voucher: Voucher, new_inventories: List[InventorySchema], db: Session): - old_set = set([(i.id, i.product_id) for i in voucher.inventories]) - new_set = set([(i.id_, i.product.id_) for i in new_inventories if i.id_ is not None]) + old_set = set([(i.id, i.batch.sku_id) for i in voucher.inventories]) + new_set = set([(i.id_, i.batch.sku.id_) for i in new_inventories if i.id_ is not None]) if len(new_set - old_set): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -231,7 +245,8 @@ def update_inventory(voucher: Voucher, new_inventories: List[InventorySchema], d if new_inventory.quantity > batch_quantity: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"{batch_quantity} is the maximum for {item.product.full_name}.", + detail=f"Maximum quantity available for " + f"{batch.sku.product.name} ({batch.sku.units}) is {batch_quantity}", ) if batch.name > voucher.date: raise HTTPException( @@ -255,15 +270,20 @@ def update_journals(voucher: Voucher, ven: schema_in.AccountLink, db): journals = {} amount = 0 for item in voucher.inventories: - account = item.product.account + account_id, cc_id = db.execute( + select(AccountBase.id, AccountBase.cost_centre_id) + .join(AccountBase.products) + .join(Product.skus) + .where(StockKeepingUnit.id == item.batch.sku.id) + ).one() amount += item.amount - if account.id in journals: - journals[account.id].amount += item.amount + if account_id in journals: + journals[account_id].amount += item.amount else: - journals[account.id] = Journal( + journals[account_id] = Journal( debit=-1, - cost_centre_id=account.cost_centre_id, - account_id=account.id, + cost_centre_id=cc_id, + account_id=account_id, amount=item.amount, ) journals[vendor.id] = Journal( diff --git a/brewman/brewman/routers/rate_contract.py b/brewman/brewman/routers/rate_contract.py index 8c73ff4e..7f90b451 100644 --- a/brewman/brewman/routers/rate_contract.py +++ b/brewman/brewman/routers/rate_contract.py @@ -61,7 +61,7 @@ async def save( def add_items(rate_contract: RateContract, items: List[RateContractItemSchema], db: Session) -> None: for item in items: - rci = RateContractItem(rate_contract_id=rate_contract.id, product_id=item.product.id_, price=item.price) + rci = RateContractItem(rate_contract_id=rate_contract.id, sku_id=item.sku.id_, price=item.price) rate_contract.items.append(rci) db.add(rci) @@ -100,7 +100,7 @@ def update_items(rate_contract: RateContract, items: List[RateContractItemSchema index = next((idx for (idx, d) in enumerate(items) if d.id_ == item.id), None) if index is not None: new_item = items.pop(index) - item.product_id = new_item.product.id_ + item.product_id = new_item.sku.id_ item.price = new_item.price else: db.delete(item) @@ -164,7 +164,9 @@ def rate_contract_info(item: RateContract) -> RateContractSchema: items=[ RateContractItemSchema( id=i.id, - product=ProductLink(id=i.product_id, name=i.product.full_name), + sku=ProductLink( + id=i.sku_id, name=f"{i.sku.product.name} ({i.sku.units})" if i.sku.units else i.sku.product.name + ), price=i.price, ) for i in item.items diff --git a/brewman/brewman/routers/rebase.py b/brewman/brewman/routers/rebase.py index 87397766..cb85f9f2 100644 --- a/brewman/brewman/routers/rebase.py +++ b/brewman/brewman/routers/rebase.py @@ -157,9 +157,9 @@ def opening_batches(date_: date, user_id: uuid.UUID, db: Session): sum_func = func.sum(Journal.debit * Inventory.quantity) query = db.execute( select(Batch, sum_func) - .join(Journal, Voucher.journals) - .join(Inventory, Voucher.inventories) - .join(Batch, Inventory.batch) + .join(Batch.inventories) + .join(Inventory.voucher) + .join(Voucher.journals) .where(Voucher.date < date_, Journal.cost_centre_id == CostCentre.cost_centre_purchase()) .having(sum_func != 0) .group_by(Batch) @@ -179,7 +179,6 @@ def opening_batches(date_: date, user_id: uuid.UUID, db: Session): if quantity != 0: total += quantity * batch.rate * (1 + batch.tax) * (1 - batch.discount) inventory = Inventory( - product_id=batch.product_id, batch=batch, quantity=quantity, rate=batch.rate, diff --git a/brewman/brewman/routers/reports/closing_stock.py b/brewman/brewman/routers/reports/closing_stock.py index 773e021d..a00e003a 100644 --- a/brewman/brewman/routers/reports/closing_stock.py +++ b/brewman/brewman/routers/reports/closing_stock.py @@ -5,16 +5,19 @@ from typing import List import brewman.schemas.closing_stock as schemas from fastapi import APIRouter, Request, Security -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql.expression import func, select 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.batch import Batch from ...models.cost_centre import CostCentre from ...models.inventory import Inventory from ...models.journal import Journal from ...models.product import Product +from ...models.product_group import ProductGroup +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...schemas.user import UserToken @@ -48,22 +51,26 @@ def build_report(date_: date, db: Session) -> List[schemas.ClosingStockItem]: amount_sum = func.sum(Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax)).label("amount") quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") query = db.execute( - select(Product, quantity_sum, amount_sum) - .join(Product.inventories) + select(StockKeepingUnit, quantity_sum, amount_sum) + .join(StockKeepingUnit.product) + .join(Product.product_group) + .join(StockKeepingUnit.batches) + .join(Batch.inventories) .join(Inventory.voucher) .join(Voucher.journals) + .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) .where(Voucher.date <= date_, Journal.cost_centre_id == CostCentre.cost_centre_purchase()) - .group_by(Product) + .group_by(StockKeepingUnit, Product, ProductGroup) .order_by(amount_sum.desc()) ).all() body = [] - for product, quantity, amount in query: + for sku, quantity, amount in query: if quantity != 0 and amount != 0: body.append( schemas.ClosingStockItem( - product=product.full_name, - group=product.product_group.name, + product=f"{sku.product.name} ({sku.units})", + group=sku.product.product_group.name, quantity=quantity, amount=amount, ) diff --git a/brewman/brewman/routers/reports/entries.py b/brewman/brewman/routers/reports/entries.py index c6e4a344..7764989e 100644 --- a/brewman/brewman/routers/reports/entries.py +++ b/brewman/brewman/routers/reports/entries.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime, time, timedelta from typing import List, Optional import brewman.schemas.entries as schemas @@ -23,7 +23,7 @@ router = APIRouter() def build_report( start_date: Optional[date], - finish_date: date, + finish_date: Optional[date], posted: Optional[bool], issue: bool, page_size: int, @@ -45,11 +45,13 @@ def build_report( sq = select(Voucher.id) counts = select(count(Voucher.id)) if start_date is not None: - sq = sq.where(or_(Voucher.creation_date >= start_date, Voucher.last_edit_date >= start_date)) - counts = counts.where(or_(Voucher.creation_date >= start_date, Voucher.last_edit_date >= start_date)) + sd = datetime.combine(start_date, time(5, 30)) + sq = sq.where(or_(Voucher.creation_date >= sd, Voucher.last_edit_date >= sd)) + counts = counts.where(or_(Voucher.creation_date >= sd, Voucher.last_edit_date >= sd)) if finish_date is not None: - sq = sq.where(or_(Voucher.creation_date <= finish_date, Voucher.last_edit_date <= finish_date)) - counts = counts.where(or_(Voucher.creation_date <= finish_date, Voucher.last_edit_date <= finish_date)) + fd = datetime.combine(finish_date, time(5, 30)) + timedelta(days=1) + sq = sq.where(or_(Voucher.creation_date <= fd, Voucher.last_edit_date <= fd)) + counts = counts.where(or_(Voucher.creation_date <= fd, Voucher.last_edit_date <= fd)) if posted is not None: sq = sq.where(Voucher.posted == posted) counts = counts.where(Voucher.posted == posted) diff --git a/brewman/brewman/routers/reports/product_ledger.py b/brewman/brewman/routers/reports/product_ledger.py index 02115e0e..e2e0598c 100644 --- a/brewman/brewman/routers/reports/product_ledger.py +++ b/brewman/brewman/routers/reports/product_ledger.py @@ -13,10 +13,12 @@ from sqlalchemy.sql.expression import func, select 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.batch import Batch from ...models.cost_centre import CostCentre from ...models.inventory import Inventory from ...models.journal import Journal from ...models.product import Product +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas.user import UserToken @@ -68,20 +70,22 @@ def show_data( def build_report( product_id: uuid.UUID, start_date: date, finish_date: date, db: Session ) -> List[schemas.ProductLedgerItem]: - body = [] running_total_q, running_total_a, opening = opening_balance(product_id, start_date, db) - body.append(opening) + body = opening query = db.execute( - select(Voucher, Inventory, Journal) + select(Voucher, Inventory, Journal, StockKeepingUnit) + .join(Voucher.journals) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(StockKeepingUnit.product) .options( joinedload(Journal.account, innerjoin=True), joinedload(Journal.cost_centre, innerjoin=True), ) .where( - Voucher.id == Inventory.voucher_id, - Voucher.id == Journal.voucher_id, - Inventory.product_id == product_id, + StockKeepingUnit.product_id == product_id, Journal.cost_centre_id != CostCentre.cost_centre_purchase(), Voucher.date >= start_date, Voucher.date <= finish_date, @@ -89,38 +93,38 @@ def build_report( .order_by(Voucher.date, Voucher.last_edit_date) ).all() - for row in query: - journal_debit = row.Journal.debit * -1 - name = ( - row.Journal.cost_centre.name - if row.Voucher.type == VoucherType.by_name("Issue").id - else row.Journal.account.name - ) - debit_q = row.Inventory.quantity if journal_debit == 1 else None - debit_a = row.Inventory.amount if journal_debit == 1 else None - credit_q = row.Inventory.quantity if journal_debit != 1 else None - credit_a = row.Inventory.amount if journal_debit != 1 else None + for voucher, inventory, journal, stockKeepingUnit in query: + journal_debit = journal.debit * -1 + name = journal.cost_centre.name if voucher.type == VoucherType.by_name("Issue").id else journal.account.name + debit_q = inventory.quantity if journal_debit == 1 else None + debit_a = inventory.amount if journal_debit == 1 else None + debit_u = stockKeepingUnit.units if journal_debit == 1 else None + credit_q = inventory.quantity if journal_debit != 1 else None + credit_a = inventory.amount if journal_debit != 1 else None + credit_u = stockKeepingUnit.units if journal_debit != 1 else None - running_total_q += row.Inventory.quantity * journal_debit - running_total_a += row.Inventory.amount * journal_debit + running_total_q += inventory.quantity * journal_debit + running_total_a += inventory.amount * journal_debit body.append( schemas.ProductLedgerItem( - id=row.Voucher.id, - date=row.Voucher.date.strftime("%d-%b-%Y"), + id=voucher.id, + date=voucher.date.strftime("%d-%b-%Y"), name=name, url=[ "/", - VoucherType.by_id(row.Voucher.type).name.replace(" ", "-").lower(), - str(row.Voucher.id), + VoucherType.by_id(voucher.type).name.replace(" ", "-").lower(), + str(voucher.id), ], - type=VoucherType.by_id(row.Voucher.type).name, - narration=row.Voucher.narration, - posted=row.Voucher.posted or VoucherType.by_id(row.Voucher.type).name == "Issue", + type=VoucherType.by_id(voucher.type).name, + narration=voucher.narration, + posted=voucher.posted or VoucherType.by_id(voucher.type).name == "Issue", debitQuantity=debit_q, debitAmount=debit_a, + debitUnit=debit_u, creditQuantity=credit_q, creditAmount=credit_a, + creditUnit=credit_u, runningQuantity=running_total_q, runningAmount=running_total_a, ) @@ -132,49 +136,50 @@ def build_report( def opening_balance( product_id: uuid.UUID, start_date: date, db: Session ) -> (Decimal, Decimal, schemas.ProductLedgerItem): - quantity, amount = db.execute( + row = db.execute( select( - func.sum(Inventory.quantity * Journal.debit), - func.sum(Inventory.amount * Journal.debit), + StockKeepingUnit.units, + func.sum(Inventory.quantity * Journal.debit).label("quantity"), + func.sum(Inventory.amount * Journal.debit).label("amount"), ) + .join(Inventory.batch) + .join(Batch.sku) .join(Inventory.voucher) .join(Voucher.journals) .where( Voucher.id == Inventory.voucher_id, Voucher.id == Journal.voucher_id, - Inventory.product_id == product_id, + StockKeepingUnit.product_id == product_id, Journal.cost_centre_id == CostCentre.cost_centre_purchase(), Voucher.date < start_date, ) - ).one() + .group_by(StockKeepingUnit.units) + ).all() - if quantity and quantity > 0: - debit_quantity = quantity - debit_amount = amount - else: - debit_quantity = None - debit_amount = None - - if quantity is None: - quantity = 0 - amount = 0 + quantity = sum(r.quantity for r in row) + amount = sum(r.amount for r in row) return ( quantity, amount, - schemas.ProductLedgerItem( - id=None, - date=start_date.strftime("%d-%b-%Y"), - name="Opening Balance", - url=[], - type="Opening Balance", - narration="", - posted=True, - debitQuantity=debit_quantity, - debitAmount=debit_amount, - creditQuantity=0, - creditAmount=0, - runningQuantity=quantity, - runningAmount=amount, - ), + [ + schemas.ProductLedgerItem( + id=None, + date=start_date.strftime("%d-%b-%Y"), + name="Opening Balance", + url=[], + type="Opening Balance", + narration="", + posted=True, + debitQuantity=q, + debitAmount=a, + debitUnit=u, + creditQuantity=0, + creditAmount=0, + creditUnit="", + runningQuantity=quantity, + runningAmount=amount, + ) + for u, q, a in row + ], ) diff --git a/brewman/brewman/routers/reports/purchase_entries.py b/brewman/brewman/routers/reports/purchase_entries.py index 40956e7f..470bc64c 100644 --- a/brewman/brewman/routers/reports/purchase_entries.py +++ b/brewman/brewman/routers/reports/purchase_entries.py @@ -5,11 +5,15 @@ import brewman.schemas.purchase_entries as schemas from fastapi import APIRouter, Request, Security from sqlalchemy import select -from sqlalchemy.orm import Session +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.batch import Batch +from ...models.inventory import Inventory +from ...models.journal import Journal +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas.user import UserToken @@ -51,7 +55,18 @@ def build_report(start_date: date, finish_date: date, db: Session) -> List[schem db.execute( select(Voucher) .join(Voucher.journals) + .join(Journal.account) .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(StockKeepingUnit.product) + .options( + contains_eager(Voucher.journals).contains_eager(Journal.account), + contains_eager(Voucher.inventories) + .contains_eager(Inventory.batch) + .contains_eager(Batch.sku) + .contains_eager(StockKeepingUnit.product), + ) .where( Voucher.date >= start_date, Voucher.date <= finish_date, @@ -76,7 +91,7 @@ def build_report(start_date: date, finish_date: date, db: Session) -> List[schem VoucherType.by_id(voucher.type).name.replace(" ", "-").lower(), str(voucher.id), ], - product=item.product.full_name, + product=item.batch.sku.product.name, quantity=item.quantity, rate=item.rate, tax=item.tax, diff --git a/brewman/brewman/routers/reports/purchases.py b/brewman/brewman/routers/reports/purchases.py index 633f1823..fc723b24 100644 --- a/brewman/brewman/routers/reports/purchases.py +++ b/brewman/brewman/routers/reports/purchases.py @@ -1,5 +1,4 @@ -import datetime - +from datetime import datetime from typing import List import brewman.schemas.purchases as schemas @@ -11,10 +10,12 @@ from sqlalchemy.sql.expression import desc, func, select 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.batch import Batch from ...models.cost_centre import CostCentre from ...models.inventory import Inventory from ...models.journal import Journal from ...models.product import Product +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas.user import UserToken @@ -62,25 +63,26 @@ def build_report( amount_sum = func.sum(Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax)).label("amount") query = db.execute( select(Product, quantity_sum, amount_sum) - .join(Product.inventories) - .join(Inventory.voucher) .join(Voucher.journals) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(StockKeepingUnit.product) .where( - Voucher.date >= datetime.datetime.strptime(start_date, "%d-%b-%Y"), - Voucher.date <= datetime.datetime.strptime(finish_date, "%d-%b-%Y"), + Voucher.date >= datetime.strptime(start_date, "%d-%b-%Y"), + Voucher.date <= datetime.strptime(finish_date, "%d-%b-%Y"), Voucher.type != VoucherType.by_name("Issue").id, Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) .group_by(Product) .order_by(desc(amount_sum)) ).all() - total_amount = 0 for product, quantity, amount in query: rate = amount / quantity if quantity != 0 else 0 total_amount += amount row = schemas.PurchasesItem( - name=product.full_name, + name=product.name, quantity=quantity, rate=rate, amount=amount, diff --git a/brewman/brewman/routers/reports/raw_material_cost.py b/brewman/brewman/routers/reports/raw_material_cost.py index f82611e0..dd265324 100644 --- a/brewman/brewman/routers/reports/raw_material_cost.py +++ b/brewman/brewman/routers/reports/raw_material_cost.py @@ -13,11 +13,13 @@ 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.account_base import AccountBase +from ...models.batch import Batch from ...models.cost_centre import CostCentre from ...models.inventory import Inventory from ...models.journal import Journal from ...models.product import Product from ...models.product_group import ProductGroup +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...schemas.user import UserToken @@ -125,43 +127,45 @@ def build_report_id( sum_gross = func.sum(Inventory.amount * Journal.debit).label("gross") query = db.execute( - select(Product, sum_quantity, sum_net, sum_gross) - .join(Product.inventories) + select(Product, ProductGroup, sum_quantity, sum_net, sum_gross) + .join(Inventory.batch) + .join(Batch.sku) + .join(StockKeepingUnit.product) + .join(Product.product_group) .join(Inventory.voucher) .join(Voucher.journals) - .join(Product.product_group) .where( Voucher.date >= start_date, Voucher.date <= finish_date, Voucher.type == 3, Journal.cost_centre_id == cost_centre_id, ) - .group_by(Product, Journal.debit, ProductGroup.name) + .group_by(Product, ProductGroup, Journal.debit, ProductGroup.name) .order_by(ProductGroup.name, sum_net.desc()) ).all() groups = {} counter = 0 list_ = [] - for product, quantity, net, gross in query: - if product.product_group_id in groups: - group = groups[product.product_group_id] + for product, pg, quantity, net, gross in query: + if pg.id in groups: + group = groups[pg.id] group.net += net group.gross += gross else: counter += 500 group = schemas.RawMaterialCostItem( - group=product.product_group.name, + group=pg.name, net=net, gross=gross, order=counter, heading=True, ) - groups[product.product_group_id] = group + groups[pg.id] = group counter += 1 list_.append( schemas.RawMaterialCostItem( - name=product.full_name, + name=product.name, quantity=quantity, net=net, gross=gross, diff --git a/brewman/brewman/routers/reports/stock_movement.py b/brewman/brewman/routers/reports/stock_movement.py index 7564ee49..56a0eb7f 100644 --- a/brewman/brewman/routers/reports/stock_movement.py +++ b/brewman/brewman/routers/reports/stock_movement.py @@ -5,16 +5,19 @@ from typing import List import brewman.schemas.stock_movement as schemas from fastapi import APIRouter, Request, Security -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, contains_eager from sqlalchemy.sql.expression import func, select 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.batch import Batch from ...models.cost_centre import CostCentre from ...models.inventory import Inventory from ...models.journal import Journal from ...models.product import Product +from ...models.product_group import ProductGroup +from ...models.stock_keeping_unit import StockKeepingUnit from ...models.voucher import Voucher from ...models.voucher_type import VoucherType from ...schemas.user import UserToken @@ -52,77 +55,89 @@ def build_stock_movement(start_date: date, finish_date: date, db: Session) -> Li dict_ = {} quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") openings = db.execute( - select(Product, quantity_sum) - .join(Product.inventories) + select(StockKeepingUnit, quantity_sum) + .join(StockKeepingUnit.product) + .join(Product.product_group) + .join(StockKeepingUnit.batches) + .join(Batch.inventories) .join(Inventory.voucher) .join(Voucher.journals) + .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) .where(Voucher.date < start_date, Journal.cost_centre_id == CostCentre.cost_centre_purchase()) - .group_by(Product) + .group_by(StockKeepingUnit, Product, ProductGroup) ).all() - for product, quantity in openings: - dict_[product.id] = schemas.StockMovementItem( - id=product.id, - name=product.full_name, - group=product.product_group.name, + for sku, quantity in openings: + dict_[sku.id] = schemas.StockMovementItem( + id=sku.id, + name=f"{sku.product.name} ({sku.units})", + group=sku.product.product_group.name, opening=Decimal(round(quantity, 2)), purchase=0, issue=0, closing=0, - url=["/", "product-ledger", str(product.id)], + url=["/", "product-ledger", str(sku.product.id)], ) purchases = db.execute( - select(Product, quantity_sum) - .join(Product.inventories) + select(StockKeepingUnit, quantity_sum) + .join(StockKeepingUnit.product) + .join(Product.product_group) + .join(StockKeepingUnit.batches) + .join(Batch.inventories) .join(Inventory.voucher) .join(Voucher.journals) + .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) .where( Voucher.date >= start_date, Voucher.date <= finish_date, Voucher.type != VoucherType.by_name("Issue").id, Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(Product) + .group_by(StockKeepingUnit, Product, ProductGroup) ).all() - for product, quantity in purchases: - if product.id in dict_: - dict_[product.id].purchase = Decimal(round(quantity, 2)) + for sku, quantity in purchases: + if sku.id in dict_: + dict_[sku.id].purchase = Decimal(round(quantity, 2)) else: - dict_[product.id] = schemas.StockMovementItem( - id=product.id, - name=product.full_name, - group=product.product_group.name, + dict_[sku.id] = schemas.StockMovementItem( + id=sku.id, + name=f"{sku.product.name} ({sku.units})", + group=sku.product.product_group.name, opening=0, purchase=Decimal(round(quantity, 2)), issue=0, closing=0, - url=["/", "product-ledger", str(product.id)], + url=["/", "product-ledger", str(sku.product.id)], ) issues = db.execute( - select(Product, quantity_sum) - .join(Product.inventories) + select(StockKeepingUnit, quantity_sum) + .join(StockKeepingUnit.product) + .join(Product.product_group) + .join(StockKeepingUnit.batches) + .join(Batch.inventories) .join(Inventory.voucher) .join(Voucher.journals) + .options(contains_eager(StockKeepingUnit.product).contains_eager(Product.product_group)) .where( Voucher.date >= start_date, Voucher.date <= finish_date, Voucher.type == VoucherType.by_name("Issue").id, Journal.cost_centre_id == CostCentre.cost_centre_purchase(), ) - .group_by(Product) + .group_by(StockKeepingUnit, Product, ProductGroup) ).all() - for product, quantity in issues: - if product.id in dict_: - dict_[product.id].issue = Decimal(round(quantity * -1, 2)) + for sku, quantity in issues: + if sku.id in dict_: + dict_[sku.id].issue = Decimal(round(quantity * -1, 2)) else: - dict_[product.id] = schemas.StockMovementItem( - id=product.id, - name=product.full_name, - group=product.product_group.name, + dict_[sku.id] = schemas.StockMovementItem( + id=sku.id, + name=f"{sku.product.name} ({sku.units})", + group=sku.product.product_group.name, opening=0, purchase=0, issue=Decimal(round(quantity * -1, 2)), closing=0, - url=["/", "product-ledger", str(product.id)], + url=["/", "product-ledger", str(sku.product.id)], ) list_ = [value for key, value in dict_.items()] diff --git a/brewman/brewman/routers/reset_stock.py b/brewman/brewman/routers/reset_stock.py deleted file mode 100644 index 85cbeb81..00000000 --- a/brewman/brewman/routers/reset_stock.py +++ /dev/null @@ -1,142 +0,0 @@ -import uuid - -from decimal import Decimal - -from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import func, select -from sqlalchemy.orm import Session - -from ..core.security import get_current_active_user as get_user -from ..db.session import SessionFuture -from ..models.account_base import AccountBase -from ..models.batch import Batch -from ..models.cost_centre import CostCentre -from ..models.inventory import Inventory -from ..models.journal import Journal -from ..models.product import Product -from ..models.voucher import Voucher -from ..models.voucher_type import VoucherType -from ..schemas.settings import ResetStock -from ..schemas.user import UserToken - - -router = APIRouter() - - -@router.post("/{id_}") -def reset_stock( - id_: uuid.UUID, - item: ResetStock, - user: UserToken = Security(get_user, scopes=["reset-stock"]), -): - with SessionFuture() as db: - product: Product = db.execute(select(Product).where(Product.id == id_)).scalar_one() - - if item.reset_date > item.stock_date: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Reset cannot be after the stock date", - ) - - change = round(item.quantity, 2) - get_closing_stock(product, item.stock_date, db=db) - if change == 0: - return {"No Change Needed"} - final = get_closing_stock(product, db=db) - if final + change < 0: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Current Quantity will get negative. Cannot proceed", - ) - - batch = get_last_batch(product, db) - set_batches(batch, final + change, db) - - create_voucher(batch, change, item.reset_date, user.id_, db) - db.commit() - return {} - - -def get_closing_stock(product, finish_date=None, db=None): - query = ( - select(func.sum(Inventory.quantity * Journal.debit)) - .join(Voucher) - .where( - Voucher.id == Inventory.voucher_id, - Voucher.id == Journal.voucher_id, - Inventory.product_id == product.id, - Journal.cost_centre_id == CostCentre.cost_centre_purchase(), - ) - ) - if finish_date is not None: - query = query.where(Voucher.date <= finish_date) - result = db.execute(query).scalar() - return 0 if result is None else result - - -def get_last_batch(product, db): - batch = ( - db.execute(select(Batch).where(Batch.product_id == product.id).order_by(Batch.name.desc())) - .scalars() - .one_or_none() - ) - if batch is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Details for the product exist. Just add a purchase entry", - ) - return batch - - -def set_batches(batch: Batch, quantity: Decimal, db: Session): - batch.quantity_remaining = quantity - batches = ( - db.execute(select(Batch).where(Batch.id != batch.id, Batch.product_id == batch.product_id)).scalars().all() - ) - for item in batches: - item.quantity_remaining = 0 - - -def create_voucher(batch, quantity, date_, user_id, db): - voucher = Voucher( - date=date_, - narration="Product Reset", - user_id=user_id, - type_=VoucherType.by_name("Issue"), - ) - db.add(voucher) - - if quantity > 0: - source = CostCentre.cost_centre_overall() - destination = CostCentre.cost_centre_purchase() - else: - destination = CostCentre.cost_centre_overall() - source = CostCentre.cost_centre_purchase() - - inventory = Inventory( - product_id=batch.product.id, - quantity=abs(quantity), - rate=batch.rate, - tax=batch.tax, - discount=batch.discount, - batch=batch, - ) - voucher.inventories.append(inventory) - db.add(inventory) - - amount = round(inventory.amount, 2) - source = Journal( - debit=-1, - account_id=AccountBase.all_purchases(), - amount=amount, - cost_centre_id=source, - ) - voucher.journals.append(source) - db.add(source) - destination = Journal( - debit=1, - account_id=AccountBase.all_purchases(), - amount=amount, - cost_centre_id=destination, - ) - voucher.journals.append(destination) - db.add(destination) diff --git a/brewman/brewman/routers/voucher.py b/brewman/brewman/routers/voucher.py index 82b59bed..4da1f196 100644 --- a/brewman/brewman/routers/voucher.py +++ b/brewman/brewman/routers/voucher.py @@ -240,7 +240,7 @@ def voucher_info(voucher, db): json_voucher["incentive"] = next(x.amount for x in voucher.journals if x.account_id == Account.incentive_id()) for item in voucher.inventories: text = ( - f"{item.product.name} ({item.product.units}) {item.batch.quantity_remaining:.2f}@" + f"{item.batch.sku.product.name} ({item.batch.sku.units}) {item.batch.quantity_remaining:.2f}@" f"{item.batch.rate:.2f} from {item.batch.name.strftime('%d-%b-%Y')}" ) json_voucher["inventories"].append( @@ -251,12 +251,6 @@ def voucher_info(voucher, db): "tax": item.tax, "discount": item.discount, "amount": item.amount, - "product": { - "id": item.product.id, - "name": item.product.full_name, - "units": item.product.units, - "price": item.rate, - }, "batch": { "id": item.batch.id, "name": text, @@ -264,9 +258,11 @@ def voucher_info(voucher, db): "tax": item.batch.tax, "discount": item.batch.discount, "rate": item.batch.rate, - "product": { - "id": item.batch.product.id, - "name": item.batch.product.full_name, + "sku": { + "id": item.batch.sku.id, + "name": f"{item.batch.sku.product.name} ({item.batch.sku.units})" + if item.batch.sku.units + else item.batch.sku.product.name, }, }, } @@ -440,4 +436,4 @@ def get_batch_quantity(id_: uuid.UUID, voucher_id: Optional[uuid.UUID], db: Sess ) if voucher_id is not None: query = query.where(Voucher.id != voucher_id) - return db.execute(query).scalar_one() + return db.execute(query).scalar_one() or 0 diff --git a/brewman/brewman/schemas/batch.py b/brewman/brewman/schemas/batch.py index 27849271..7cbee326 100644 --- a/brewman/brewman/schemas/batch.py +++ b/brewman/brewman/schemas/batch.py @@ -1,6 +1,7 @@ import uuid from decimal import Decimal +from typing import Optional from brewman.schemas import to_camel from brewman.schemas.product import ProductLink @@ -8,9 +9,9 @@ from pydantic import BaseModel class Batch(BaseModel): - id_: uuid.UUID + id_: Optional[uuid.UUID] name: str - product: ProductLink + sku: ProductLink quantity_remaining: Decimal rate: Decimal tax: Decimal diff --git a/brewman/brewman/schemas/inventory.py b/brewman/brewman/schemas/inventory.py index 47e24cab..e16358f0 100644 --- a/brewman/brewman/schemas/inventory.py +++ b/brewman/brewman/schemas/inventory.py @@ -5,14 +5,12 @@ from typing import Optional from brewman.schemas import to_camel from brewman.schemas.batch import Batch -from brewman.schemas.product import ProductLink from pydantic import BaseModel, Field class Inventory(BaseModel): id_: Optional[uuid.UUID] - product: ProductLink - batch: Optional[Batch] + batch: Batch quantity: Decimal = Field(ge=0, multiple_of=0.01) rate: Decimal = Field(ge=0, multiple_of=0.01) tax: Decimal = Field(ge=0, multiple_of=0.00001, le=5) diff --git a/brewman/brewman/schemas/product.py b/brewman/brewman/schemas/product.py index f16dbe64..a3a835d9 100644 --- a/brewman/brewman/schemas/product.py +++ b/brewman/brewman/schemas/product.py @@ -1,12 +1,12 @@ import uuid -from decimal import Decimal -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field from . import to_camel from .product_group import ProductGroupLink +from .stock_keeping_unit import StockKeepingUnit class ProductLink(BaseModel): @@ -19,13 +19,8 @@ class ProductLink(BaseModel): class ProductIn(BaseModel): name: str = Field(..., min_length=1) - units: str - fraction: Decimal = Field(ge=1, default=1) - fraction_units: str - product_yield: Decimal = Field(gt=0, le=1, default=1) + skus: List[StockKeepingUnit] product_group: ProductGroupLink = Field(...) - price: Decimal = Field(ge=0, multiple_of=0.01, default=0) - sale_price: Decimal = Field(ge=0, multiple_of=0.01, default=0) is_active: bool is_purchased: bool is_sold: bool @@ -44,6 +39,7 @@ class Product(ProductIn): class ProductBlank(ProductIn): name: str + skus: List[StockKeepingUnit] product_group: Optional[ProductGroupLink] is_fixture: bool diff --git a/brewman/brewman/schemas/product_ledger.py b/brewman/brewman/schemas/product_ledger.py index 95020bd0..3bbc1d74 100644 --- a/brewman/brewman/schemas/product_ledger.py +++ b/brewman/brewman/schemas/product_ledger.py @@ -20,8 +20,10 @@ class ProductLedgerItem(BaseModel): narration: str debit_quantity: Optional[Decimal] debit_amount: Optional[Decimal] + debit_unit: Optional[str] credit_quantity: Optional[Decimal] credit_amount: Optional[Decimal] + credit_unit: Optional[str] running_quantity: Decimal running_amount: Decimal posted: bool diff --git a/brewman/brewman/schemas/rate_contract_item.py b/brewman/brewman/schemas/rate_contract_item.py index d080bc47..dd24e9f4 100644 --- a/brewman/brewman/schemas/rate_contract_item.py +++ b/brewman/brewman/schemas/rate_contract_item.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field class RateContractItem(BaseModel): id_: Optional[uuid.UUID] - product: ProductLink + sku: ProductLink price: Decimal = Field(ge=0, multiple_of=0.01) class Config: diff --git a/brewman/brewman/schemas/settings.py b/brewman/brewman/schemas/settings.py index f1ae8cbb..d457536d 100644 --- a/brewman/brewman/schemas/settings.py +++ b/brewman/brewman/schemas/settings.py @@ -1,7 +1,6 @@ import uuid from datetime import date, datetime -from decimal import Decimal from typing import List, Optional from pydantic import BaseModel, validator @@ -84,27 +83,3 @@ class LockInformation(BaseModel): class Maintenance(BaseModel): enabled: bool user: Optional[str] - - -class ResetStock(BaseModel): - quantity: Decimal - stock_date: date - reset_date: date - - class Config: - alias_generator = to_camel - json_encoders = { - date: lambda v: v.strftime("%d-%b-%Y"), - } - - @validator("stock_date", pre=True) - def parse_stock_date(cls, value): - if isinstance(value, date): - return value - return datetime.strptime(value, "%d-%b-%Y").date() - - @validator("reset_date", pre=True) - def parse_reset_date(cls, value): - if isinstance(value, date): - return value - return datetime.strptime(value, "%d-%b-%Y").date() diff --git a/brewman/brewman/schemas/stock_keeping_unit.py b/brewman/brewman/schemas/stock_keeping_unit.py new file mode 100644 index 00000000..8204ed05 --- /dev/null +++ b/brewman/brewman/schemas/stock_keeping_unit.py @@ -0,0 +1,23 @@ +import uuid + +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field + +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) + sale_price: Decimal = Field(ge=0, multiple_of=0.01, default=0) + + class Config: + anystr_strip_whitespace = True + alias_generator = to_camel diff --git a/overlord/src/app/app-routing.module.ts b/overlord/src/app/app-routing.module.ts index 80ebb99b..42960976 100644 --- a/overlord/src/app/app-routing.module.ts +++ b/overlord/src/app/app-routing.module.ts @@ -24,7 +24,9 @@ const appRoutes: Routes = [ { path: 'batch-integrity-report', loadChildren: () => - import('./batch-integrity-report/batch-integrity-report.module').then((mod) => mod.BatchIntegrityReportModule), + import('./batch-integrity-report/batch-integrity-report.module').then( + (mod) => mod.BatchIntegrityReportModule, + ), }, { path: 'cash-flow', diff --git a/overlord/src/app/core/batch.ts b/overlord/src/app/core/batch.ts index 90b485c2..487c2b97 100644 --- a/overlord/src/app/core/batch.ts +++ b/overlord/src/app/core/batch.ts @@ -1,22 +1,22 @@ import { Product } from './product'; export class Batch { - id: string; + id: string | null; name: string; quantityRemaining: number; tax: number; discount: number; rate: number; - product: Product; + sku: Product; public constructor(init?: Partial) { - this.id = ''; + this.id = null; this.name = ''; this.quantityRemaining = 0; this.tax = 0; this.discount = 0; this.rate = 0; - this.product = new Product(); + this.sku = new Product(); Object.assign(this, init); } } diff --git a/overlord/src/app/core/inventory.ts b/overlord/src/app/core/inventory.ts index 79289d99..10269572 100644 --- a/overlord/src/app/core/inventory.ts +++ b/overlord/src/app/core/inventory.ts @@ -8,8 +8,7 @@ export class Inventory { tax: number; discount: number; amount: number; - product: Product; - batch: Batch | null; + batch: Batch; public constructor(init?: Partial) { this.quantity = 0; @@ -17,7 +16,6 @@ export class Inventory { this.tax = 0; this.discount = 0; this.amount = 0; - this.product = new Product(); this.batch = new Batch(); Object.assign(this, init); } diff --git a/overlord/src/app/core/product.ts b/overlord/src/app/core/product.ts index 32c06d40..4b9d8118 100644 --- a/overlord/src/app/core/product.ts +++ b/overlord/src/app/core/product.ts @@ -1,15 +1,35 @@ import { ProductGroup } from './product-group'; -export class Product { - id: string | undefined; - code: number; - name: string; +export class StockKeepingUnit { + isDefault: boolean; units: string; fraction: number; fractionUnits: string; productYield: number; price: number; salePrice: number; + + public constructor(init?: Partial) { + this.isDefault = false; + this.units = ''; + this.fraction = 1; + this.fractionUnits = ''; + this.productYield = 1; + this.price = 0; + this.salePrice = 0; + Object.assign(this, init); + } +} + +export class Product { + id: string | undefined; + code: number; + name: string; + skus: StockKeepingUnit[]; + price: number | undefined; + tax: number | undefined; + discount: number | undefined; + isActive: boolean; isFixture: boolean; isPurchased: boolean; @@ -20,12 +40,8 @@ export class Product { public constructor(init?: Partial) { this.code = 0; this.name = ''; - this.units = ''; - this.fraction = 1; - this.fractionUnits = ''; - this.productYield = 1; - this.price = 0; - this.salePrice = 0; + this.skus = []; + this.isActive = true; this.isFixture = false; this.isPurchased = true; diff --git a/overlord/src/app/issue/issue-dialog.component.ts b/overlord/src/app/issue/issue-dialog.component.ts index 3596fd0b..ee5f8a6e 100644 --- a/overlord/src/app/issue/issue-dialog.component.ts +++ b/overlord/src/app/issue/issue-dialog.component.ts @@ -60,7 +60,6 @@ export class IssueDialogComponent implements OnInit { const formValue = this.form.value; const quantity = this.math.parseAmount(formValue.quantity, 2); this.data.inventory.batch = this.batch; - this.data.inventory.product = this.batch.product; this.data.inventory.quantity = quantity; this.data.inventory.rate = this.batch.rate; this.data.inventory.tax = this.batch.tax; diff --git a/overlord/src/app/issue/issue.component.html b/overlord/src/app/issue/issue.component.html index a6997743..ab182e85 100644 --- a/overlord/src/app/issue/issue.component.html +++ b/overlord/src/app/issue/issue.component.html @@ -91,7 +91,7 @@ Product - {{ row.product.name }} + {{ row.batch.sku.name }} diff --git a/overlord/src/app/issue/issue.component.ts b/overlord/src/app/issue/issue.component.ts index e3383d3d..a39845c2 100644 --- a/overlord/src/app/issue/issue.component.ts +++ b/overlord/src/app/issue/issue.component.ts @@ -167,7 +167,7 @@ export class IssueComponent implements OnInit, AfterViewInit, OnDestroy { return; } const oldFiltered = this.voucher.inventories.filter( - (x) => x.product.id === (this.batch as Batch).product.id, + (x) => x.batch.sku.id === (this.batch as Batch).sku.id, ); const old = oldFiltered.length ? oldFiltered[0] : null; if (oldFiltered.length) { @@ -192,7 +192,6 @@ export class IssueComponent implements OnInit, AfterViewInit, OnDestroy { tax: this.batch.tax, discount: this.batch.discount, amount: quantity * this.batch.rate * (1 + this.batch.tax) * (1 - this.batch.discount), - product: this.batch.product, batch: this.batch, }), ); @@ -238,8 +237,8 @@ export class IssueComponent implements OnInit, AfterViewInit, OnDestroy { } const j = result as Inventory; if ( - j.product.id !== row.product.id && - this.voucher.inventories.filter((x) => x.product.id === j.product.id).length + j.batch.sku.id !== row.batch.sku.id && + this.voucher.inventories.filter((x) => x.batch.sku.id === j.batch.sku.id).length ) { return; } diff --git a/overlord/src/app/product-ledger/product-ledger-item.ts b/overlord/src/app/product-ledger/product-ledger-item.ts index 69821e4c..74b06cc4 100644 --- a/overlord/src/app/product-ledger/product-ledger-item.ts +++ b/overlord/src/app/product-ledger/product-ledger-item.ts @@ -6,8 +6,10 @@ export class ProductLedgerItem { narration: string; debitQuantity: number; debitAmount: number; + debitUnit: string; creditQuantity: number; creditAmount: number; + creditUnit: string; runningQuantity: number; runningAmount: number; posted: boolean; @@ -21,8 +23,10 @@ export class ProductLedgerItem { this.narration = ''; this.debitQuantity = 0; this.debitAmount = 0; + this.debitUnit = ''; this.creditQuantity = 0; this.creditAmount = 0; + this.creditUnit = ''; this.runningQuantity = 0; this.runningAmount = 0; this.posted = true; diff --git a/overlord/src/app/product-ledger/product-ledger.component.html b/overlord/src/app/product-ledger/product-ledger.component.html index 00081068..c8b0ab39 100644 --- a/overlord/src/app/product-ledger/product-ledger.component.html +++ b/overlord/src/app/product-ledger/product-ledger.component.html @@ -106,9 +106,9 @@ Debit Quantity - {{ - row.debitQuantity | number: '1.2-2' - }} + {{ row.debitQuantity | number: '1.2-2' }} {{ row.debitUnit }} {{ debitQuantity | number: '1.2-2' }} @@ -128,9 +128,9 @@ Credit - {{ - row.creditQuantity | number: '1.2-2' - }} + {{ row.creditQuantity | number: '1.2-2' }} {{ row.creditUnit }} {{ creditQuantity | number: '1.2-2' }} diff --git a/overlord/src/app/product-ledger/product-ledger.component.ts b/overlord/src/app/product-ledger/product-ledger.component.ts index f5fd0a38..61154162 100644 --- a/overlord/src/app/product-ledger/product-ledger.component.ts +++ b/overlord/src/app/product-ledger/product-ledger.component.ts @@ -70,7 +70,9 @@ export class ProductLedgerComponent implements OnInit, AfterViewInit { map((x) => (x !== null && x.length >= 1 ? x : null)), debounceTime(150), distinctUntilChanged(), - switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocomplete(x))), + switchMap((x) => + x === null ? observableOf([]) : this.productSer.autocomplete(x, false, false), + ), ); } diff --git a/overlord/src/app/product/product-detail/product-detail-datasource.ts b/overlord/src/app/product/product-detail/product-detail-datasource.ts new file mode 100644 index 00000000..fbaafeb8 --- /dev/null +++ b/overlord/src/app/product/product-detail/product-detail-datasource.ts @@ -0,0 +1,16 @@ +import { DataSource } from '@angular/cdk/collections'; +import { Observable } from 'rxjs'; + +import { StockKeepingUnit } from '../../core/product'; + +export class ProductDetailDatasource extends DataSource { + constructor(private data: Observable) { + super(); + } + + connect(): Observable { + return this.data; + } + + disconnect() {} +} diff --git a/overlord/src/app/product/product-detail/product-detail-dialog.component.css b/overlord/src/app/product/product-detail/product-detail-dialog.component.css new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..4e7a07f7 --- /dev/null +++ b/overlord/src/app/product/product-detail/product-detail-dialog.component.html @@ -0,0 +1,46 @@ +

Edit Journal Entry

+
+
+
+ + Units + + + + Fraction + + + + Fraction Units + + + + Yield + + + + {{ data.isPurchased ? 'Purchase Price' : 'Cost Price' }} + + + + Sale Price + + +
+
+
+
+ + +
diff --git a/overlord/src/app/product/product-detail/product-detail-dialog.component.spec.ts b/overlord/src/app/product/product-detail/product-detail-dialog.component.spec.ts new file mode 100644 index 00000000..939f4681 --- /dev/null +++ b/overlord/src/app/product/product-detail/product-detail-dialog.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { ProductDetailDialogComponent } from './product-detail-dialog.component'; + +describe('ProductDetailDialogComponent', () => { + let component: ProductDetailDialogComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ProductDetailDialogComponent], + }).compileComponents(); + }), + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductDetailDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 00000000..a734ebfc --- /dev/null +++ b/overlord/src/app/product/product-detail/product-detail-dialog.component.ts @@ -0,0 +1,68 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { StockKeepingUnit } from '../../core/product'; + +@Component({ + selector: 'app-journal-dialog', + templateUrl: './product-detail-dialog.component.html', + styleUrls: ['./product-detail-dialog.component.css'], +}) +export class ProductDetailDialogComponent implements OnInit { + form: FormGroup; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public data: { item: StockKeepingUnit; isSold: boolean; isPurchased: boolean }, + private fb: FormBuilder, + ) { + this.form = this.fb.group({ + units: '', + fraction: '', + fractionUnits: '', + productYield: '', + price: '', + salePrice: '', + }); + } + + ngOnInit() { + 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, + salePrice: '' + this.data.item.salePrice, + }); + } + + accept(): void { + const formValue = this.form.value; + const fraction = +formValue.fraction; + if (fraction < 1) { + return; + } + const productYield = +formValue.productYield; + if (productYield < 0 || productYield > 1) { + return; + } + const price = +formValue.price; + if (price < 0) { + return; + } + const salePrice = +formValue.salePrice; + if (salePrice < 0) { + return; + } + 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.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 208d11ea..d7a5eb3f 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.html +++ b/overlord/src/app/product/product-detail/product-detail.component.html @@ -24,55 +24,10 @@ fxLayoutGap="20px" fxLayoutGap.lt-md="0px" > - + Name - - Units - - - -
- - Fraction - - - - Fraction Units - - - - Yield - - -
-
- - {{ item.isPurchased ? 'Purchase Price' : 'Cost Price' }} - - - - Sale Price - -
+

Stock Keeping Units

+
+ + Units + + + + Fraction + + + + Fraction Units + + + + Yield + + + + {{ item.isPurchased ? 'Purchase Price' : 'Cost Price' }} + + + + Sale Price + + + +
+ + + + Default + + + + + + + + Units + {{ row.units }} + + + + + Fraction + {{ row.fraction }} + + + + + Fraction Units + {{ row.fractionUnits }} + + + + + Yield + {{ row.productYield }} + + + + + Price + {{ row.price | currency: 'INR' }} + + + + + Sale Price + {{ + row.salePrice | currency: 'INR' + }} + + + + + Action + + + + + + + + + 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 5102063b..c7b12fa1 100644 --- a/overlord/src/app/product/product-detail/product-detail.component.ts +++ b/overlord/src/app/product/product-detail/product-detail.component.ts @@ -1,14 +1,19 @@ import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +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'; -import { Product } from '../../core/product'; +import { Product, StockKeepingUnit } from '../../core/product'; import { ProductGroup } from '../../core/product-group'; import { ToasterService } from '../../core/toaster.service'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { ProductService } from '../product.service'; +import { ProductDetailDatasource } from './product-detail-datasource'; +import { ProductDetailDialogComponent } from './product-detail-dialog.component'; + @Component({ selector: 'app-product-detail', templateUrl: './product-detail.component.html', @@ -18,8 +23,21 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { @ViewChild('nameElement', { static: true }) nameElement?: ElementRef; form: FormGroup; productGroups: ProductGroup[] = []; + public skus = new BehaviorSubject([]); + dataSource: ProductDetailDatasource = new ProductDetailDatasource(this.skus); item: Product = new Product(); + displayedColumns = [ + 'isDefault', + 'units', + 'fraction', + 'fractionUnits', + 'yield', + 'price', + 'salePrice', + 'action', + ]; + constructor( private route: ActivatedRoute, private router: Router, @@ -31,12 +49,14 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.form = this.fb.group({ code: { value: '', disabled: true }, name: '', - units: '', - fraction: '', - fractionUnits: '', - productYield: '', - price: '', - salePrice: '', + addRow: this.fb.group({ + units: '', + fraction: '', + fractionUnits: '', + productYield: '', + price: '', + salePrice: '', + }), isPurchased: '', isSold: '', isActive: '', @@ -51,6 +71,8 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.productGroups = data.productGroups; this.showItem(data.item); }); + this.dataSource = new ProductDetailDatasource(this.skus); + this.skus.next(this.item.skus); } showItem(item: Product) { @@ -58,12 +80,14 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { this.form.setValue({ code: this.item.code || '(Auto)', name: this.item.name || '', - units: this.item.units || '', - fraction: this.item.fraction || '', - fractionUnits: this.item.fractionUnits || '', - productYield: this.item.productYield || '', - price: this.item.price || '', - salePrice: this.item.salePrice || '', + addRow: { + units: '', + fraction: '', + fractionUnits: '', + productYield: '', + price: '', + salePrice: '', + }, isPurchased: this.item.isPurchased, isSold: this.item.isSold, isActive: this.item.isActive, @@ -79,6 +103,79 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { }, 0); } + addRow() { + const formValue = (this.form.get('addRow') as FormControl).value; + const fraction = +formValue.fraction; + if (fraction < 1) { + this.toaster.show('Danger', 'Fraction has to be >= 1'); + return; + } + const productYield = +formValue.productYield; + if (productYield < 0 || productYield > 1) { + this.toaster.show('Danger', 'Product Yield has to be > 0 and <= 1'); + return; + } + const price = +formValue.price; + if (price < 0) { + this.toaster.show('Danger', 'Price has to be >= 0'); + return; + } + const salePrice = +formValue.salePrice; + if (salePrice < 0) { + this.toaster.show('Danger', 'Sale Price has to be >= 0'); + return; + } + this.item.skus.push( + new StockKeepingUnit({ + units: formValue.units, + fraction, + fractionUnits: formValue.fractionUnits, + productYield, + price, + salePrice, + }), + ); + this.skus.next(this.item.skus); + this.resetAddRow(); + } + + resetAddRow() { + (this.form.get('addRow') as FormControl).reset({ + units: '', + fraction: '', + fractionUnits: '', + productYield: '', + price: '', + salePrice: '', + }); + } + + editRow(row: StockKeepingUnit) { + const dialogRef = this.dialog.open(ProductDetailDialogComponent, { + width: '750px', + data: { + item: { ...row }, + isSold: this.item.isSold, + isPurchased: this.item.isPurchased, + }, + }); + + dialogRef.afterClosed().subscribe((result: boolean | StockKeepingUnit) => { + if (!result) { + return; + } + const j = result as StockKeepingUnit; + Object.assign(row, j); + this.skus.next(this.item.skus); + this.resetAddRow(); + }); + } + + deleteRow(row: StockKeepingUnit) { + this.item.skus.splice(this.item.skus.indexOf(row), 1); + this.skus.next(this.item.skus); + } + save() { this.ser.saveOrUpdate(this.getItem()).subscribe( () => { @@ -119,12 +216,6 @@ export class ProductDetailComponent implements OnInit, AfterViewInit { getItem(): Product { const formModel = this.form.value; this.item.name = formModel.name; - this.item.units = formModel.units; - this.item.fraction = +formModel.fraction; - this.item.fractionUnits = formModel.fractionUnits; - this.item.productYield = +formModel.productYield; - this.item.price = +formModel.price; - this.item.salePrice = +formModel.salePrice; this.item.isPurchased = formModel.isPurchased; this.item.isSold = formModel.isSold; this.item.isActive = formModel.isActive; @@ -134,4 +225,9 @@ 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-datasource.ts b/overlord/src/app/product/product-list/product-list-datasource.ts index a9f2f1ed..c9d5eed1 100644 --- a/overlord/src/app/product/product-list/product-list-datasource.ts +++ b/overlord/src/app/product/product-list/product-list-datasource.ts @@ -8,9 +8,6 @@ import { map, tap } from 'rxjs/operators'; import { Product } from '../../core/product'; import { ProductGroup } from '../../core/product-group'; -/** Simple sort comparator for example ID/Name columns (for client-side sorting). */ -const compare = (a: string | number, b: string | number, isAsc: boolean) => - (a < b ? -1 : 1) * (isAsc ? 1 : -1); export class ProductListDataSource extends DataSource { private filterValue = ''; @@ -60,7 +57,7 @@ export class ProductListDataSource extends DataSource { return this.filterValue.split(' ').reduce( (p: Product[], c: string) => p.filter((x) => { - const productString = `${x.code} ${x.name} ${x.units} ${x.productGroup}${ + const productString = `${x.code} ${x.name} ${x.productGroup?.name}${ x.isPurchased ? ' purchased' : ' made' }${x.isSold ? 'sold' : 'used'}${x.isActive ? 'active' : 'deactive'}`.toLowerCase(); return productString.indexOf(c) !== -1; @@ -103,3 +100,7 @@ export class ProductListDataSource extends DataSource { }); } } + +/** Simple sort comparator for example ID/Name columns (for client-side sorting). */ +const compare = (a: string | number, b: string | number, isAsc: boolean) => + (a < b ? -1 : 1) * (isAsc ? 1 : -1); diff --git a/overlord/src/app/product/product-list/product-list.component.css b/overlord/src/app/product/product-list/product-list.component.css index e69de29b..81d40c07 100644 --- a/overlord/src/app/product/product-list/product-list.component.css +++ b/overlord/src/app/product/product-list/product-list.component.css @@ -0,0 +1,3 @@ +.bold { + font-weight: bold; +} 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 bc911c8c..5160f488 100644 --- a/overlord/src/app/product/product-list/product-list.component.html +++ b/overlord/src/app/product/product-list/product-list.component.html @@ -34,25 +34,41 @@ Name - {{ row.name }} ({{ - showExtended ? row.fraction + ' ' + row.fractionUnits + ' = 1 ' : '' - }}{{ row.units }}) + + + Cost Price - {{ row.price | currency: 'INR' }} + +
    +
  • + {{ sku.price | currency: 'INR' }} +
  • +
+
Yield - {{ row.productYield | percent: '1.2-2' }} + +
    +
  • + {{ sku.productYield | percent: '1.2-2' }} +
  • +
+
diff --git a/overlord/src/app/product/product-list/product-list.component.ts b/overlord/src/app/product/product-list/product-list.component.ts index ada78601..0fb091e7 100644 --- a/overlord/src/app/product/product-list/product-list.component.ts +++ b/overlord/src/app/product/product-list/product-list.component.ts @@ -27,11 +27,10 @@ export class ProductListComponent implements OnInit, AfterViewInit { /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ displayedColumns: string[] = []; - // eslint-disable-next-line no-underscore-dangle - private _showExtended = false; + private _showExtended = true; constructor(private route: ActivatedRoute, private fb: FormBuilder, private toCsv: ToCsvService) { - this.showExtended = false; + this.showExtended = true; this.form = this.fb.group({ filter: '', }); @@ -44,12 +43,10 @@ export class ProductListComponent implements OnInit, AfterViewInit { } get showExtended(): boolean { - // eslint-disable-next-line no-underscore-dangle return this._showExtended; } set showExtended(value: boolean) { - // eslint-disable-next-line no-underscore-dangle this._showExtended = value; if (value) { this.displayedColumns = ['name', 'costPrice', 'productYield', 'productGroup', 'info']; @@ -76,7 +73,6 @@ export class ProductListComponent implements OnInit, AfterViewInit { } exportCsv() { - // eslint-disable-next-line @typescript-eslint/naming-convention const headers = { Code: 'code', Name: 'name', diff --git a/overlord/src/app/product/product.module.ts b/overlord/src/app/product/product.module.ts index 44cc9433..6a89718e 100644 --- a/overlord/src/app/product/product.module.ts +++ b/overlord/src/app/product/product.module.ts @@ -7,6 +7,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatOptionModule } from '@angular/material/core'; +import { MatDialogModule } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatPaginatorModule } from '@angular/material/paginator'; @@ -15,6 +16,7 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSortModule } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; +import { ProductDetailDialogComponent } from './product-detail/product-detail-dialog.component'; import { ProductDetailComponent } from './product-detail/product-detail.component'; import { ProductListComponent } from './product-list/product-list.component'; import { ProductRoutingModule } from './product-routing.module'; @@ -37,7 +39,8 @@ import { ProductRoutingModule } from './product-routing.module'; MatCheckboxModule, ReactiveFormsModule, ProductRoutingModule, + MatDialogModule, ], - declarations: [ProductListComponent, ProductDetailComponent], + declarations: [ProductListComponent, ProductDetailComponent, ProductDetailDialogComponent], }) export class ProductModule {} diff --git a/overlord/src/app/product/product.service.ts b/overlord/src/app/product/product.service.ts index 32b761d3..43e0e2f6 100644 --- a/overlord/src/app/product/product.service.ts +++ b/overlord/src/app/product/product.service.ts @@ -54,10 +54,13 @@ export class ProductService { autocomplete( query: string, extended: boolean = false, + skus: boolean = true, date?: string, vendorId?: string, ): Observable { - const options = { params: new HttpParams().set('q', query).set('e', extended.toString()) }; + const options = { + params: new HttpParams().set('q', query).set('e', extended.toString()).set('s', skus), + }; if (!!vendorId && !!date) { options.params = options.params.set('v', vendorId as string).set('d', date as string); } diff --git a/overlord/src/app/purchase-return/purchase-return-dialog.component.ts b/overlord/src/app/purchase-return/purchase-return-dialog.component.ts index f4cb752b..e4b971b3 100644 --- a/overlord/src/app/purchase-return/purchase-return-dialog.component.ts +++ b/overlord/src/app/purchase-return/purchase-return-dialog.component.ts @@ -60,7 +60,6 @@ export class PurchaseReturnDialogComponent implements OnInit { const formValue = this.form.value; const quantity = this.math.parseAmount(formValue.quantity, 2); this.data.inventory.batch = this.batch; - this.data.inventory.product = this.batch.product; this.data.inventory.quantity = quantity; this.data.inventory.rate = this.batch.rate; this.data.inventory.tax = this.batch.tax; diff --git a/overlord/src/app/purchase-return/purchase-return.component.html b/overlord/src/app/purchase-return/purchase-return.component.html index 3ddefb42..c82f0a6d 100644 --- a/overlord/src/app/purchase-return/purchase-return.component.html +++ b/overlord/src/app/purchase-return/purchase-return.component.html @@ -110,7 +110,7 @@ Product - {{ row.product.name }} + {{ row.batch.sku.name }} diff --git a/overlord/src/app/purchase-return/purchase-return.component.ts b/overlord/src/app/purchase-return/purchase-return.component.ts index f4e58444..40c14b36 100644 --- a/overlord/src/app/purchase-return/purchase-return.component.ts +++ b/overlord/src/app/purchase-return/purchase-return.component.ts @@ -180,7 +180,7 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy return; } const oldFiltered = this.voucher.inventories.filter( - (x) => x.product.id === (this.batch as Batch).product.id, + (x) => x.batch.sku.id === (this.batch as Batch).sku.id, ); if (oldFiltered.length) { this.toaster.show('Danger', 'Product already added'); @@ -193,7 +193,6 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy tax: this.batch.tax, discount: this.batch.discount, amount: quantity * this.batch.rate * (1 + this.batch.tax) * (1 - this.batch.discount), - product: this.batch.product, batch: this.batch, }), ); @@ -236,8 +235,8 @@ export class PurchaseReturnComponent implements OnInit, AfterViewInit, OnDestroy } const j = result as Inventory; if ( - j.product.id !== row.product.id && - this.voucher.inventories.filter((x) => x.product.id === j.product.id).length + j.batch.sku.id !== row.batch.sku.id && + this.voucher.inventories.filter((x) => x.batch.sku.id === j.batch.sku.id).length ) { return; } diff --git a/overlord/src/app/purchase/purchase-dialog.component.ts b/overlord/src/app/purchase/purchase-dialog.component.ts index b97c0353..9d500e9c 100644 --- a/overlord/src/app/purchase/purchase-dialog.component.ts +++ b/overlord/src/app/purchase/purchase-dialog.component.ts @@ -6,6 +6,7 @@ import { round } from 'mathjs'; import { Observable, of as observableOf } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { Batch } from '../core/batch'; import { Inventory } from '../core/inventory'; import { Product } from '../core/product'; import { ProductService } from '../product/product.service'; @@ -47,13 +48,13 @@ export class PurchaseDialogComponent implements OnInit { ngOnInit() { this.form.setValue({ - product: this.data.inventory.product, + product: this.data.inventory.batch?.sku, quantity: this.data.inventory.quantity, price: this.data.inventory.rate, tax: this.data.inventory.tax, discount: this.data.inventory.discount, }); - this.product = this.data.inventory.product; + this.product = this.data.inventory.batch?.sku; } displayFn(product?: Product): string { @@ -71,7 +72,10 @@ export class PurchaseDialogComponent implements OnInit { const price = this.math.parseAmount(formValue.price, 2); const tax = this.math.parseAmount(formValue.tax, 5); const discount = this.math.parseAmount(formValue.discount, 5); - this.data.inventory.product = this.product; + if (this.data.inventory.batch === null) { + this.data.inventory.batch = new Batch(); + } + this.data.inventory.batch.sku = this.product; this.data.inventory.quantity = quantity; this.data.inventory.rate = price; this.data.inventory.tax = tax; diff --git a/overlord/src/app/purchase/purchase.component.html b/overlord/src/app/purchase/purchase.component.html index adef8895..57671e7f 100644 --- a/overlord/src/app/purchase/purchase.component.html +++ b/overlord/src/app/purchase/purchase.component.html @@ -134,7 +134,7 @@ Product - {{ row.product.name }} + {{ row.batch.sku.name }} diff --git a/overlord/src/app/purchase/purchase.component.ts b/overlord/src/app/purchase/purchase.component.ts index cc340fd1..f5c4f5a2 100644 --- a/overlord/src/app/purchase/purchase.component.ts +++ b/overlord/src/app/purchase/purchase.component.ts @@ -13,6 +13,7 @@ import { AuthService } from '../auth/auth.service'; import { Account } from '../core/account'; import { AccountBalance } from '../core/account-balance'; import { AccountService } from '../core/account.service'; +import { Batch } from '../core/batch'; import { DbFile } from '../core/db-file'; import { Inventory } from '../core/inventory'; import { Product } from '../core/product'; @@ -100,6 +101,7 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { : this.productSer.autocomplete( x, false, + true, moment(this.form.value.date).format('DD-MMM-YYYY'), this.form.value.account.id, ), @@ -201,11 +203,11 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { const discount = this.product.isRateContracted ? 0 : this.math.parseAmount(formValue.discount, 5); - if (price <= 0 || tax < 0 || discount < 0) { + if ((price as number) <= 0 || (tax as number) < 0 || (discount as number) < 0) { return; } const oldFiltered = this.voucher.inventories.filter( - (x) => x.product.id === (this.product as Product).id, + (x) => x.batch?.sku.id === (this.product as Product).id, ); if (oldFiltered.length) { this.toaster.show('Danger', 'Product already added'); @@ -217,9 +219,8 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { rate: price, tax, discount, - amount: round(quantity * price * (1 + tax) * (1 - discount), 2), - product: this.product, - batch: null, + amount: round(quantity * (price as number) * (1 + tax) * (1 - discount), 2), + batch: new Batch({ sku: this.product }), }), ); this.resetAddRow(); @@ -264,8 +265,8 @@ export class PurchaseComponent implements OnInit, AfterViewInit, OnDestroy { } const j = result as Inventory; if ( - j.product.id !== row.product.id && - this.voucher.inventories.filter((x) => x.product.id === j.product.id).length + j.batch?.sku.id !== row.batch?.sku.id && + this.voucher.inventories.filter((x) => x.batch?.sku.id === j.batch?.sku.id).length ) { return; } diff --git a/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.html b/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.html index c95077fb..c2dfa32a 100644 --- a/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.html +++ b/overlord/src/app/rate-contract/rate-contract-detail/rate-contract-detail.component.html @@ -121,7 +121,7 @@ Product - {{ row.product.name }} + {{ row.sku.name }} 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 04b53a0f..94f38f05 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 @@ -119,9 +119,7 @@ export class RateContractDetailComponent implements OnInit, AfterViewInit { if (this.product === null || price <= 0) { return; } - const oldFiltered = this.item.items.filter( - (x) => x.product.id === (this.product as Product).id, - ); + const oldFiltered = this.item.items.filter((x) => x.sku.id === (this.product as Product).id); if (oldFiltered.length) { this.toaster.show('Danger', 'Product already added'); return; @@ -129,7 +127,7 @@ export class RateContractDetailComponent implements OnInit, AfterViewInit { this.item.items.push( new RateContractItem({ price, - product: this.product, + sku: this.product, }), ); this.resetAddRow(); diff --git a/overlord/src/app/rate-contract/rate-contract-item.ts b/overlord/src/app/rate-contract/rate-contract-item.ts index 67a08bc6..8ab2a154 100644 --- a/overlord/src/app/rate-contract/rate-contract-item.ts +++ b/overlord/src/app/rate-contract/rate-contract-item.ts @@ -2,11 +2,11 @@ import { Product } from '../core/product'; export class RateContractItem { id: string | undefined; - product: Product; + sku: Product; price: number; public constructor(init?: Partial) { - this.product = new Product(); + this.sku = new Product(); this.price = 0; Object.assign(this, init); } diff --git a/overlord/src/app/rate-contract/rate-contract-list/rate-contract-list.component.html b/overlord/src/app/rate-contract/rate-contract-list/rate-contract-list.component.html index ebc20eb2..32d10cbf 100644 --- a/overlord/src/app/rate-contract/rate-contract-list/rate-contract-list.component.html +++ b/overlord/src/app/rate-contract/rate-contract-list/rate-contract-list.component.html @@ -30,7 +30,7 @@
  • - {{ item.product.name }} @ {{ item.price | currency: 'INR' }} + {{ item.sku.name }} @ {{ item.price | currency: 'INR' }}
diff --git a/overlord/src/app/settings/settings.component.html b/overlord/src/app/settings/settings.component.html index 1fd61093..16eb593b 100644 --- a/overlord/src/app/settings/settings.component.html +++ b/overlord/src/app/settings/settings.component.html @@ -232,74 +232,6 @@ - - - -
- - - - - - - - - - - - Product - - - {{ - product.name - }} - - - - Quantity - - - - -
-
-
-
diff --git a/overlord/src/app/settings/settings.component.ts b/overlord/src/app/settings/settings.component.ts index 6c289286..0b091a34 100644 --- a/overlord/src/app/settings/settings.component.ts +++ b/overlord/src/app/settings/settings.component.ts @@ -1,18 +1,14 @@ import { Component, OnInit } from '@angular/core'; import { FormArray, 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 * as moment from 'moment'; -import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; -import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; import { environment } from '../../environments/environment'; import { AuthService } from '../auth/auth.service'; import { AccountType } from '../core/account-type'; -import { Product } from '../core/product'; import { ToasterService } from '../core/toaster.service'; -import { ProductService } from '../product/product.service'; import { ConfirmDialogComponent } from '../shared/confirm-dialog/confirm-dialog.component'; import { LockDataSource } from './lock-datasource'; @@ -40,10 +36,6 @@ export class SettingsComponent implements OnInit { rebaseDataForm: FormGroup; - resetStockForm: FormGroup; - product: Product = new Product(); - products: Observable; - maintenance: { enabled: boolean; user: string }; displayedColumns = ['index', 'validity', 'voucherTypes', 'accountTypes', 'lock', 'action']; @@ -58,7 +50,6 @@ export class SettingsComponent implements OnInit { private toaster: ToasterService, public auth: AuthService, private ser: SettingsService, - private productSer: ProductService, ) { const startDate = moment().date(1); const finishDate = moment().date(startDate.daysInMonth()); @@ -82,21 +73,7 @@ export class SettingsComponent implements OnInit { date: moment(), }); - this.resetStockForm = this.fb.group({ - resetDate: moment(), - stockDate: moment(), - product: null, - quantity: 0, - }); this.listenToLockForm(); - // Listen to Product Changes - this.products = (this.resetStockForm.get('product') as FormControl).valueChanges.pipe( - startWith(''), - map((x) => (x !== null && x.length >= 1 ? x : null)), - debounceTime(150), - distinctUntilChanged(), - switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocomplete(x))), - ); this.maintenance = { enabled: false, user: '' }; this.version = environment.version; } @@ -246,48 +223,6 @@ export class SettingsComponent implements OnInit { ); } - displayFn(product?: Product): string { - return product ? product.name : ''; - } - - selectedProduct(event: MatAutocompleteSelectedEvent): void { - this.product = event.option.value; - } - - confirmResetStock(): void { - const resetDate = moment((this.resetStockForm.get('resetDate') as FormControl).value).format( - 'DD-MMM-YYYY', - ); - const stockDate = moment((this.resetStockForm.get('stockDate') as FormControl).value).format( - 'DD-MMM-YYYY', - ); - const quantity = +(this.resetStockForm.get('quantity') as FormControl).value; - const dialogRef = this.dialog.open(ConfirmDialogComponent, { - width: '250px', - data: { - title: 'Reset the stock of?', - content: `Are you sure you want to reset the stock of ${this.product.name}? This action is destructive and cannot be undone.`, - }, - }); - - dialogRef.afterClosed().subscribe((result: boolean) => { - if (result) { - this.resetStock(resetDate, stockDate, this.product, quantity); - } - }); - } - - resetStock(resetDate: string, stockDate: string, product: Product, quantity: number) { - this.ser.resetStock(resetDate, stockDate, product, quantity).subscribe( - () => { - this.toaster.show('Success', 'Stock has been reset!'); - }, - (error) => { - this.toaster.show('Danger', error); - }, - ); - } - toggleMaintenance() { this.ser.setMaintenance(!this.maintenance.enabled).subscribe((x) => { this.maintenance = x; diff --git a/overlord/src/app/settings/settings.service.ts b/overlord/src/app/settings/settings.service.ts index b90c5ca8..863f74ab 100644 --- a/overlord/src/app/settings/settings.service.ts +++ b/overlord/src/app/settings/settings.service.ts @@ -41,18 +41,6 @@ export class SettingsService { >; } - resetStock( - resetDate: string, - stockDate: string, - product: Product, - quantity: number, - ): Observable { - const url = '/api/reset-stock'; - return this.http - .post(`${url}/${product.id}`, { stockDate, resetDate, quantity }) - .pipe(catchError(this.log.handleError(serviceName, 'resetStock'))) as Observable; - } - rebaseDatabase(date: string): Observable> { const url = '/api/rebase'; return this.http