From 3b46ac97bc2672b7ec9f90b0b5224f6bd69e5168 Mon Sep 17 00:00:00 2001 From: Amritanshu <git@tanshu.com> Date: Fri, 16 Aug 2024 10:23:50 +0530 Subject: [PATCH] Feature: Mozimo Product Register --- .../82e5c8d18382_mozimo_product_ledger.py | 46 ++++ brewman/brewman/db/base.py | 1 + brewman/brewman/main.py | 3 + .../brewman/models/mozimo_stock_register.py | 63 +++++ .../reports/mozimo_product_register.py | 203 ++++++++++++++ .../schemas/mozimo_product_register.py | 85 ++++++ overlord/src/app/app.routes.ts | 4 + .../app/core/nav-bar/nav-bar.component.html | 1 + .../mozimo-product-register-datasource.ts | 66 +++++ .../mozimo-product-register-item.ts | 28 ++ .../mozimo-product-register.component.css | 17 ++ .../mozimo-product-register.component.html | 164 ++++++++++++ .../mozimo-product-register.component.spec.ts | 22 ++ .../mozimo-product-register.component.ts | 250 ++++++++++++++++++ .../mozimo-product-register.resolver.spec.ts | 18 ++ .../mozimo-product-register.resolver.ts | 12 + .../mozimo-product-register.routes.ts | 34 +++ .../mozimo-product-register.service.spec.ts | 17 ++ .../mozimo-product-register.service.ts | 55 ++++ .../mozimo-product-register.ts | 18 ++ 20 files changed, 1107 insertions(+) create mode 100644 brewman/alembic/versions/82e5c8d18382_mozimo_product_ledger.py create mode 100644 brewman/brewman/models/mozimo_stock_register.py create mode 100644 brewman/brewman/routers/reports/mozimo_product_register.py create mode 100644 brewman/brewman/schemas/mozimo_product_register.py create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register-datasource.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register-item.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.component.css create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.component.html create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.component.spec.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.component.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.spec.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.routes.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.service.spec.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.service.ts create mode 100644 overlord/src/app/mozimo-product-register/mozimo-product-register.ts diff --git a/brewman/alembic/versions/82e5c8d18382_mozimo_product_ledger.py b/brewman/alembic/versions/82e5c8d18382_mozimo_product_ledger.py new file mode 100644 index 00000000..d9109e1a --- /dev/null +++ b/brewman/alembic/versions/82e5c8d18382_mozimo_product_ledger.py @@ -0,0 +1,46 @@ +"""mozimo product ledger + +Revision ID: 82e5c8d18382 +Revises: fab52fb911e4 +Create Date: 2024-08-16 06:57:58.336202 + +""" + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "82e5c8d18382" +down_revision = "fab52fb911e4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "mozimo_stock_register", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column("sku_id", sa.Uuid(), nullable=False), + sa.Column("received", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("sale", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("nc", sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column("display", sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column("ageing", sa.Numeric(precision=15, scale=2), nullable=True), + sa.Column("last_edit_date", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["sku_id"], ["stock_keeping_units.id"], name=op.f("fk_mozimo_stock_register_sku_id_stock_keeping_units") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mozimo_stock_register")), + sa.UniqueConstraint("date", "sku_id", name=op.f("uq_mozimo_stock_register_date")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("mozimo_stock_register") + # ### end Alembic commands ### diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index 79020d24..96011167 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -17,6 +17,7 @@ from ..models.incentive import Incentive # noqa: F401 from ..models.inventory import Inventory # noqa: F401 from ..models.journal import Journal # noqa: F401 from ..models.login_history import LoginHistory # noqa: F401 +from ..models.mozimo_stock_register import MozimoStockRegister # noqa: F401 from ..models.period import Period # noqa: F401 from ..models.permission import Permission # noqa: F401 from ..models.price import Price # noqa: F401 diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index 99f36cd5..ff216e0b 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -53,6 +53,7 @@ from .routers.reports import ( daybook, entries, ledger, + mozimo_product_register, net_transactions, non_contract_purchase, product_ledger, @@ -114,6 +115,8 @@ app.include_router(trial_balance.router, prefix="/api/trial-balance", tags=["rep app.include_router(entries.router, prefix="/api/entries", tags=["reports"]) app.include_router(batch_integrity.router, prefix="/api/batch-integrity", tags=["reports"]) app.include_router(non_contract_purchase.router, prefix="/api/non-contract-purchase", tags=["reports"]) +app.include_router(mozimo_product_register.router, prefix="/api/mozimo-product-register", tags=["mozimo"]) + app.include_router(issue_grid.router, prefix="/api/issue-grid", tags=["vouchers"]) app.include_router(batch.router, prefix="/api/batch", tags=["vouchers"]) diff --git a/brewman/brewman/models/mozimo_stock_register.py b/brewman/brewman/models/mozimo_stock_register.py new file mode 100644 index 00000000..8a459a95 --- /dev/null +++ b/brewman/brewman/models/mozimo_stock_register.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import uuid + +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Date, DateTime, ForeignKey, Numeric, UniqueConstraint, Uuid +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..db.base_class import reg + + +if TYPE_CHECKING: + from .stock_keeping_unit import StockKeepingUnit + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class MozimoStockRegister: + __tablename__ = "mozimo_stock_register" + __table_args__ = (UniqueConstraint("date", "sku_id"),) + id_: Mapped[uuid.UUID] = mapped_column("id", Uuid, primary_key=True, insert_default=uuid.uuid4) + date_: Mapped[date] = mapped_column("date", Date, nullable=False) + sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + received: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + sale: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + nc: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=False) + # The physical stock in the display area + display: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=True) + # The physical stock in the ageing room + ageing: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2), nullable=True) + last_edit_date: Mapped[datetime] = mapped_column(DateTime(), nullable=False) + + sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit") + + def __init__( + self, + date_: datetime.date, + received: Decimal, + sale: Decimal, + nc: Decimal, + display: Decimal, + ageing: Decimal, + sku_id: uuid.UUID | None = None, + sku: StockKeepingUnit | None = None, + id_: uuid.UUID | None = None, + last_edit_date: datetime | None = None, + ): + self.date_ = date_ + self.received = received + self.sale = sale + self.nc = nc + self.display = display + self.ageing = ageing + if sku_id is not None: + self.sku_id = sku_id + if sku is not None: + self.sku = sku + self.sku_id = sku.id + if id_ is not None: + self.id_ = id_ + self.last_edit_date = last_edit_date or datetime.now(UTC).replace(tzinfo=None) diff --git a/brewman/brewman/routers/reports/mozimo_product_register.py b/brewman/brewman/routers/reports/mozimo_product_register.py new file mode 100644 index 00000000..2b4bf797 --- /dev/null +++ b/brewman/brewman/routers/reports/mozimo_product_register.py @@ -0,0 +1,203 @@ +import uuid + +from datetime import UTC, date, datetime +from decimal import Decimal + +from fastapi import APIRouter, HTTPException, Request, Security, status +from sqlalchemy import desc +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import select + +import brewman.schemas.mozimo_product_register as schemas + +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.mozimo_stock_register import MozimoStockRegister +from ...models.stock_keeping_unit import StockKeepingUnit +from ...schemas.user import UserToken +from ..attendance import date_range + + +router = APIRouter() + + +@router.get("", response_model=schemas.MozimoProductRegister) +def show_blank( + request: Request, + user: UserToken = Security(get_user, scopes=["ledger"]), +) -> schemas.MozimoProductRegister: + return schemas.MozimoProductRegister( + start_date=get_start_date(request.session), + finish_date=get_finish_date(request.session), + product=None, + body=[], + ) + + +@router.get("/{id_}", response_model=schemas.MozimoProductRegister) +def show_data( + id_: uuid.UUID, + request: Request, + s: str | None = None, + f: str | None = None, + user: UserToken = Security(get_user, scopes=["ledger"]), +) -> schemas.MozimoProductRegister: + with SessionFuture() as db: + sku = db.execute(select(StockKeepingUnit).where(StockKeepingUnit.id == id_)).scalar_one() + start_date = datetime.strptime(s or get_start_date(request.session), "%d-%b-%Y").date() + finish_date = datetime.strptime(f or get_finish_date(request.session), "%d-%b-%Y").date() + body = build_report(sku.id, start_date, finish_date, db) + set_period(start_date, finish_date, request.session) + return schemas.MozimoProductRegister( + start_date=start_date, + finish_date=finish_date, + product=schemas.ProductLink(id_=sku.id, name=f"{sku.product.name} ({sku.units})"), + body=body, + ) + + +def build_report( + product_id: uuid.UUID, start_date: date, finish_date: date, db: Session +) -> list[schemas.MozimoProductRegisterItem]: + body = [] + ob = opening_balance(product_id, start_date, db) + + for date_ in date_range(start_date, finish_date, inclusive=True): + item = ( + db.execute( + select(MozimoStockRegister).where( + MozimoStockRegister.sku_id == product_id, + MozimoStockRegister.date_ == date_, + ) + ) + .scalars() + .one_or_none() + ) + + body.append( + schemas.MozimoProductRegisterItem( + id_=None if item is None else item.id_, + date_=date_, + opening=ob, + received=Decimal(0) if item is None else item.received, + sale=Decimal(0) if item is None else item.sale, + nc=Decimal(0) if item is None else item.nc, + display=None if item is None else item.display, + ageing=None if item is None else item.ageing, + last_edit_date=None if item is None else item.last_edit_date, + ) + ) + if item is not None: + if item.ageing is not None or item.display is not None: + closing = (item.ageing or 0) + (item.display or 0) + else: + closing = ob + item.received - item.sale - item.nc + ob = closing # Setting the cb as ob for next iteration + return body + + +def opening_balance(product_id: uuid.UUID, start_date: date, db: Session) -> Decimal: + opening = db.execute( + select(MozimoStockRegister.display + MozimoStockRegister.ageing) + .order_by(desc(MozimoStockRegister.date_)) + .where( + MozimoStockRegister.sku_id == product_id, + MozimoStockRegister.date_ < start_date, + ) + ).scalar() + return Decimal(0) if opening is None else opening + + +@router.post("", response_model=schemas.MozimoProductRegister) +def save_route( + request: Request, + data: schemas.MozimoProductRegister, + user: UserToken = Security(get_user, scopes=["ledger"]), +) -> schemas.MozimoProductRegister: + try: + if any(i for i in data.body if i.received < 0): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Received cannot be less than 0.", + ) + if any(i for i in data.body if i.sale < 0): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Sale cannot be less than 0.", + ) + if any(i for i in data.body if i.nc < 0): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="No Charge cannot be less than 0.", + ) + if any(i for i in data.body if i.display is not None and i.display < 0): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Quantity on display can be Blank, but cannot be less than 0.", + ) + if any(i for i in data.body if i.ageing is not None and i.ageing < 0): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Quantity in ageing room can be Blank, but cannot be less than 0.", + ) + with SessionFuture() as db: + now = datetime.now(UTC).replace(tzinfo=None) + ob = opening_balance(data.product.id_, data.start_date, db) + for item in data.body: + if item.ageing is not None or item.display is not None: + closing = (item.ageing or 0) + (item.display or 0) + else: + closing = ob + item.received - item.sale - item.nc + ob = closing # Setting the cb as ob for next iteration + if closing < 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Closing Stock cannot be less than 0.", + ) + + for item in data.body: + old = ( + db.execute( + select(MozimoStockRegister).where( + MozimoStockRegister.sku_id == data.product.id_, + MozimoStockRegister.date_ == item.date_, + ) + ) + .scalars() + .one_or_none() + ) + if old is not None: + if ( + old.received != item.received + or old.sale != item.sale + or old.nc != item.nc + or old.display != item.display + or old.ageing != item.ageing + ): + old.received = item.received + old.sale = item.sale + old.nc = item.nc + old.display = item.display + old.ageing = item.ageing + old.last_edit_date = now + else: + entry = MozimoStockRegister( + item.date_, item.received, item.sale, item.nc, item.display, item.ageing, data.product.id_ + ) + db.add(entry) + + body = build_report(data.product.id_, data.start_date, data.finish_date, db) + db.commit() + return schemas.MozimoProductRegister( + start_date=data.start_date, + finish_date=data.finish_date, + product=data.product, + body=body, + ) + except SQLAlchemyError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) diff --git a/brewman/brewman/schemas/mozimo_product_register.py b/brewman/brewman/schemas/mozimo_product_register.py new file mode 100644 index 00000000..f8c276f9 --- /dev/null +++ b/brewman/brewman/schemas/mozimo_product_register.py @@ -0,0 +1,85 @@ +import uuid + +from datetime import date, datetime + +from pydantic import ( + BaseModel, + ConfigDict, + FieldSerializationInfo, + field_serializer, + field_validator, +) + +from . import Daf, to_camel +from .product import ProductLink + + +class MozimoProductRegisterItem(BaseModel): + id_: uuid.UUID | None = None + date_: date + opening: Daf + received: Daf + sale: Daf + nc: Daf + display: Daf | None + ageing: Daf | None + last_edit_date: datetime | None = None + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + @field_validator("date_", mode="before") + @classmethod + def parse_date(cls, value: date | str) -> date: + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() + + @field_serializer("date_") + def serialize_date(self, value: date, info: FieldSerializationInfo) -> str: + return value.strftime("%d-%b-%Y") + + @field_validator("last_edit_date", mode="before") + @classmethod + def parse_last_edit_date(cls, value: None | datetime | str) -> datetime | None: + if value is None or value == "": + return None + if isinstance(value, datetime): + return value + return datetime.strptime(value, "%d-%b-%Y %H:%M") + + @field_serializer("last_edit_date") + def serialize_last_edit_date(self, value: datetime, info: FieldSerializationInfo) -> str | None: + return None if value is None else value.strftime("%d-%b-%Y %H:%M") + + +class MozimoProductRegister(BaseModel): + start_date: date | None = None + finish_date: date | None = None + product: ProductLink | None = None + body: list[MozimoProductRegisterItem] + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + @field_validator("start_date", mode="before") + @classmethod + def parse_start_date(cls, value: date | str | None) -> date | None: + if value is None: + return None + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() + + @field_serializer("start_date") + def serialize_start_date(self, value: date, info: FieldSerializationInfo) -> str | None: + return None if value is None else value.strftime("%d-%b-%Y") + + @field_validator("finish_date", mode="before") + @classmethod + def parse_finish_date(cls, value: date | str | None) -> date | None: + if value is None: + return None + if isinstance(value, date): + return value + return datetime.strptime(value, "%d-%b-%Y").date() + + @field_serializer("finish_date") + def serialize_finish_date(self, value: date, info: FieldSerializationInfo) -> str | None: + return None if value is None else value.strftime("%d-%b-%Y") diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts index 67e2e0f9..e38904bd 100644 --- a/overlord/src/app/app.routes.ts +++ b/overlord/src/app/app.routes.ts @@ -89,6 +89,10 @@ export const routes: Routes = [ path: 'ledger', loadChildren: () => import('./ledger/ledger.routes').then((mod) => mod.routes), }, + { + path: 'mozimo-product-register', + loadChildren: () => import('./mozimo-product-register/mozimo-product-register.routes').then((mod) => mod.routes), + }, { path: 'net-transactions', loadChildren: () => import('./net-transactions/net-transactions.routes').then((mod) => mod.routes), diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html index fb5969e1..66510dcb 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -35,6 +35,7 @@ <a mat-menu-item routerLink="/rate-contracts">Rate Contracts</a> <a mat-menu-item routerLink="/batch-integrity-report">Batch Integrity</a> <a mat-menu-item routerLink="/non-contract-purchase">Non Contract Purchases</a> + <a mat-menu-item routerLink="/mozimo-product-register">Mozimo Product Register</a> </mat-menu> <button mat-button [matMenuTriggerFor]="productReportMenu">Product Reports</button> diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register-datasource.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register-datasource.ts new file mode 100644 index 00000000..645bc494 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register-datasource.ts @@ -0,0 +1,66 @@ +import { DataSource } from '@angular/cdk/collections'; +import { EventEmitter } from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { merge, Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { MozimoProductRegisterItem } from './mozimo-product-register-item'; + +export class MozimoProductRegisterDataSource extends DataSource<MozimoProductRegisterItem> { + public data: MozimoProductRegisterItem[] = []; + public paginator?: MatPaginator; + constructor(public dataObs: Observable<MozimoProductRegisterItem[]>) { + super(); + } + + connect(): Observable<MozimoProductRegisterItem[]> { + const dataMutations: EventEmitter<PageEvent>[] = []; + const d = this.dataObs.pipe( + tap((x) => { + this.data = x; + }), + ); + if (this.paginator) { + dataMutations.push((this.paginator as MatPaginator).page); + } + + return merge(d, ...dataMutations).pipe( + map(() => this.calculate([...this.data])), + tap(() => { + if (this.paginator) { + this.paginator.length = this.data.length; + } + }), + map((x: MozimoProductRegisterItem[]) => this.getPagedData(x)), + ); + } + + disconnect() {} + + private calculate(data: MozimoProductRegisterItem[]): MozimoProductRegisterItem[] { + if (data.length === 0) { + return data; + } + let ob = data[0].opening; + data.forEach((item) => { + item.opening = ob; + if (item.ageing !== null || item.display !== null) { + item.closing = (item.ageing ?? 0) + (item.display ?? 0); + item.variance = item.opening + item.received - item.sale - item.nc - item.closing; + } else { + item.closing = item.opening + item.received - item.sale - item.nc; + item.variance = 0; + } + ob = item.closing; + }); + return data; + } + + private getPagedData(data: MozimoProductRegisterItem[]) { + if (this.paginator === undefined) { + return data; + } + const startIndex = this.paginator.pageIndex * this.paginator.pageSize; + return data.splice(startIndex, this.paginator.pageSize); + } +} diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register-item.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register-item.ts new file mode 100644 index 00000000..9fc0468e --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register-item.ts @@ -0,0 +1,28 @@ +export class MozimoProductRegisterItem { + id: string | null; + date: string; + opening: number; + received: number; + sale: number; + nc: number; + display: number | null; + ageing: number | null; + variance: number | null; + closing: number; + lastEditDate: string | null; + + public constructor(init?: Partial<MozimoProductRegisterItem>) { + this.id = null; + this.date = ''; + this.opening = 0; + this.received = 0; + this.sale = 0; + this.nc = 0; + this.display = null; + this.ageing = null; + this.variance = null; + this.closing = 0; + this.lastEditDate = null; + Object.assign(this, init); + } +} diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.component.css b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.css new file mode 100644 index 00000000..bfd7f853 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.css @@ -0,0 +1,17 @@ +.right { + display: flex; + justify-content: flex-end; +} + +.first { + margin-right: 4px; +} + +.middle { + margin-left: 4px; + margin-right: 4px; +} + +.last { + margin-left: 4px; +} diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.component.html b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.html new file mode 100644 index 00000000..0fe89837 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.html @@ -0,0 +1,164 @@ +<mat-card> + <mat-card-header> + <mat-card-title-group> + <mat-card-title>Mozimo Product Register</mat-card-title> + @if (dataSource.data.length) { + <button mat-icon-button (click)="exportCsv()"> + <mat-icon>save_alt</mat-icon> + </button> + } + </mat-card-title-group> + </mat-card-header> + <mat-card-content> + <form [formGroup]="form" class="flex flex-col"> + <div class="flex flex-row justify-around content-start items-start sm:max-lg:flex-col"> + <mat-form-field class="flex-auto mr-5"> + <mat-label>Start Date</mat-label> + <input + matInput + #startDateElement + [matDatepicker]="startDate" + formControlName="startDate" + autocomplete="off" + /> + <mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle> + <mat-datepicker #startDate></mat-datepicker> + </mat-form-field> + <mat-form-field class="flex-auto"> + <mat-label>Finish Date</mat-label> + <input matInput [matDatepicker]="finishDate" formControlName="finishDate" autocomplete="off" /> + <mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle> + <mat-datepicker #finishDate></mat-datepicker> + </mat-form-field> + </div> + <div class="flex flex-row justify-around content-start items-start sm:max-lg:flex-col"> + <mat-form-field class="flex-auto basis-4/5 mr-5"> + <mat-label>Product</mat-label> + <input + type="text" + matInput + #productElement + [matAutocomplete]="auto" + formControlName="product" + autocomplete="off" + /> + <mat-autocomplete + #auto="matAutocomplete" + autoActiveFirstOption + [displayWith]="displayFn" + (optionSelected)="selected($event)" + > + @for (product of products | async; track product) { + <mat-option [value]="product">{{ product.name }}</mat-option> + } + </mat-autocomplete> + </mat-form-field> + <button mat-raised-button class="flex-auto basis-1/5" color="primary" (click)="show()">Show</button> + </div> + <mat-table #table [dataSource]="dataSource" aria-label="Elements" formArrayName="items"> + <!-- Date Column --> + <ng-container matColumnDef="date"> + <mat-header-cell *matHeaderCellDef class="center first">Date</mat-header-cell> + <mat-cell *matCellDef="let row" class="center first"> + <span + matBadge="1" + matBadgeSize="small" + [matBadgeHidden]="!row.lastEditDate" + matTooltip="{{ row.lastEditDate | localTime }}" + [matTooltipDisabled]="!row.lastEditDate" + >{{ row.date }}</span + > + </mat-cell> + </ng-container> + + <!-- Opening Column --> + <ng-container matColumnDef="opening"> + <mat-header-cell *matHeaderCellDef class="right middle">Opening</mat-header-cell> + <mat-cell *matCellDef="let row" class="right middle">{{ row.opening | number: '0.2-2' }}</mat-cell> + </ng-container> + + <!-- Received Column --> + <ng-container matColumnDef="received"> + <mat-header-cell *matHeaderCellDef class="middle">Received</mat-header-cell> + <mat-cell *matCellDef="let row; let i = index" [formGroupName]="i" class="middle"> + <mat-form-field class="flex-auto"> + <mat-label>Received</mat-label> + <input matInput type="number" formControlName="received" (change)="updateReceived($event, row)" /> + </mat-form-field> + </mat-cell> + </ng-container> + + <!-- Sale Column --> + <ng-container matColumnDef="sale"> + <mat-header-cell *matHeaderCellDef class="middle">Sale</mat-header-cell> + <mat-cell *matCellDef="let row; let i = index" [formGroupName]="i" class="middle"> + <mat-form-field class="flex-auto"> + <mat-label>Sale</mat-label> + <input matInput type="number" formControlName="sale" (change)="updateSale($event, row)" /> + </mat-form-field> + </mat-cell> + </ng-container> + + <!-- Nc Column --> + <ng-container matColumnDef="nc"> + <mat-header-cell *matHeaderCellDef class="middle">Nc</mat-header-cell> + <mat-cell *matCellDef="let row; let i = index" [formGroupName]="i" class="middle"> + <mat-form-field class="flex-auto"> + <mat-label>Nc</mat-label> + <input matInput type="number" formControlName="nc" (change)="updateNc($event, row)" /> + </mat-form-field> + </mat-cell> + </ng-container> + + <!-- Display Column --> + <ng-container matColumnDef="display"> + <mat-header-cell *matHeaderCellDef class="middle">Display</mat-header-cell> + <mat-cell *matCellDef="let row; let i = index" [formGroupName]="i" class="middle"> + <mat-form-field class="flex-auto"> + <mat-label>Display</mat-label> + <input matInput type="number" formControlName="display" (change)="updateDisplay($event, row)" /> + </mat-form-field> + </mat-cell> + </ng-container> + + <!-- Ageing Column --> + <ng-container matColumnDef="ageing"> + <mat-header-cell *matHeaderCellDef class="middle">Ageing</mat-header-cell> + <mat-cell *matCellDef="let row; let i = index" [formGroupName]="i" class="middle"> + <mat-form-field class="flex-auto"> + <mat-label>Ageing</mat-label> + <input matInput type="number" formControlName="ageing" (change)="updateAgeing($event, row)" /> + </mat-form-field> + </mat-cell> + </ng-container> + + <!-- Variance Column --> + <ng-container matColumnDef="variance"> + <mat-header-cell *matHeaderCellDef class="right middle">Variance</mat-header-cell> + <mat-cell *matCellDef="let row" class="right middle">{{ row.variance | number: '0.2-2' }}</mat-cell> + </ng-container> + + <!-- Closing Column --> + <ng-container matColumnDef="closing"> + <mat-header-cell *matHeaderCellDef class="right last">Closing</mat-header-cell> + <mat-cell *matCellDef="let row" class="right last">{{ row.closing | number: '0.2-2' }}</mat-cell> + </ng-container> + + <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> + <mat-row *matRowDef="let row; columns: displayedColumns"></mat-row> + </mat-table> + + <mat-paginator + #paginator + [length]="dataSource.data.length" + [pageIndex]="0" + [pageSize]="50" + [pageSizeOptions]="[25, 50, 100, 250, 300, 5000]" + > + </mat-paginator> + </form> + </mat-card-content> + <mat-card-actions> + <button mat-raised-button color="primary" (click)="save()" [disabled]="form.pristine">Save</button> + </mat-card-actions> +</mat-card> diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.component.spec.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.spec.ts new file mode 100644 index 00000000..c2ff9e68 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MozimoProductRegisterComponent } from './mozimo-product-register.component'; + +describe('MozimoProductRegisterComponent', () => { + let component: MozimoProductRegisterComponent; + let fixture: ComponentFixture<MozimoProductRegisterComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MozimoProductRegisterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MozimoProductRegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.component.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.ts new file mode 100644 index 00000000..266cdadc --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.component.ts @@ -0,0 +1,250 @@ +import { DecimalPipe, CurrencyPipe, AsyncPipe } from '@angular/common'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { FormGroup, FormArray, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatDialog } from '@angular/material/dialog'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import moment from 'moment'; + +import { AuthService } from '../auth/auth.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ToCsvService } from '../shared/to-csv.service'; + +import { MozimoProductRegister } from './mozimo-product-register'; +import { MozimoProductRegisterDataSource } from './mozimo-product-register-datasource'; +import { MozimoProductRegisterItem } from './mozimo-product-register-item'; +import { MozimoProductRegisterService } from './mozimo-product-register.service'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { Product } from '../core/product'; +import { debounceTime, distinctUntilChanged, Observable, switchMap, of as observableOf, BehaviorSubject } from 'rxjs'; +import { ProductSku } from '../core/product-sku'; +import { ProductService } from '../product/product.service'; +import { LocalTimePipe } from '../shared/local-time.pipe'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-mozimo-product-register', + templateUrl: './mozimo-product-register.component.html', + styleUrls: ['./mozimo-product-register.component.css'], + standalone: true, + imports: [ + AsyncPipe, + CurrencyPipe, + DecimalPipe, + LocalTimePipe, + MatAutocompleteModule, + MatBadgeModule, + MatButtonModule, + MatCardModule, + MatDatepickerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatPaginatorModule, + MatTableModule, + MatTooltipModule, + ReactiveFormsModule, + ], +}) +export class MozimoProductRegisterComponent implements OnInit { + @ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator; + info: MozimoProductRegister = new MozimoProductRegister(); + body = new BehaviorSubject<MozimoProductRegisterItem[]>([]); + dataSource: MozimoProductRegisterDataSource = new MozimoProductRegisterDataSource(this.body); + + form: FormGroup<{ + startDate: FormControl<Date>; + finishDate: FormControl<Date>; + product: FormControl<string | null>; + items: FormArray< + FormGroup<{ + received: FormControl<number>; + sale: FormControl<number>; + nc: FormControl<number>; + display: FormControl<number | null>; + ageing: FormControl<number | null>; + }> + >; + }>; + /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ + + displayedColumns = ['date', 'opening', 'received', 'sale', 'nc', 'display', 'ageing', 'variance', 'closing']; + + products: Observable<ProductSku[]>; + + constructor( + private route: ActivatedRoute, + private router: Router, + private toCsv: ToCsvService, + private dialog: MatDialog, + private snackBar: MatSnackBar, + public auth: AuthService, + private ser: MozimoProductRegisterService, + private productSer: ProductService, + ) { + this.form = new FormGroup({ + startDate: new FormControl(new Date(), { nonNullable: true }), + finishDate: new FormControl(new Date(), { nonNullable: true }), + product: new FormControl<string | null>(null), + items: new FormArray< + FormGroup<{ + received: FormControl<number>; + sale: FormControl<number>; + nc: FormControl<number>; + display: FormControl<number | null>; + ageing: FormControl<number | null>; + }> + >([]), + }); + + this.products = this.form.controls.product.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged(), + switchMap((x) => (x === null ? observableOf([]) : this.productSer.autocompleteSku(x, null))), + ); + } + + ngOnInit() { + this.route.data.subscribe((value) => { + const data = value as { info: MozimoProductRegister }; + this.info = data.info; + this.form.patchValue({ + product: this.info.product?.name ?? '', + startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(), + finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(), + }); + this.form.controls.items.clear(); + this.info.body.forEach((x) => + this.form.controls.items.push( + new FormGroup({ + received: new FormControl(x.received, { nonNullable: true, validators: [Validators.min(0)] }), + sale: new FormControl(x.sale, { nonNullable: true, validators: [Validators.min(0)] }), + nc: new FormControl(x.nc, { nonNullable: true, validators: [Validators.min(0)] }), + display: new FormControl(x.display, { nonNullable: false, validators: [Validators.min(0)] }), + ageing: new FormControl(x.ageing, { nonNullable: false, validators: [Validators.min(0)] }), + }), + ), + ); + if (!this.dataSource.paginator) { + this.dataSource.paginator = this.paginator; + } + this.body.next(this.info.body); + }); + } + + displayFn(product?: Product | string): string { + return !product ? '' : typeof product === 'string' ? product : product.name; + } + + selected(event: MatAutocompleteSelectedEvent): void { + this.info.product = event.option.value; + } + + show() { + const info = this.getInfo(); + if (info.product) { + this.router.navigate(['mozimo-product-register', info.product.id], { + queryParams: { + startDate: info.startDate, + finishDate: info.finishDate, + }, + }); + } + } + + save() { + this.ser.save(this.getMozimoProductRegister()).subscribe({ + next: () => { + this.snackBar.open('', 'Success'); + }, + error: (error) => { + this.snackBar.open(error, 'Danger'); + }, + }); + } + + getMozimoProductRegister(): MozimoProductRegister { + const formModel = this.form.value; + this.info.startDate = moment(formModel.startDate).format('DD-MMM-YYYY'); + this.info.finishDate = moment(formModel.finishDate).format('DD-MMM-YYYY'); + + const array = this.form.controls.items; + this.info.body.forEach((item, index) => { + item.received = +(array.controls[index].value.received ?? 0); + item.sale = +(array.controls[index].value.sale ?? 0); + item.nc = +(array.controls[index].value.nc ?? 0); + const display = array.controls[index].value.display ?? null; + const ageing = array.controls[index].value.ageing ?? null; + item.display = display == null ? null : +display; + item.ageing = ageing == null ? null : +ageing; + }); + return this.info; + } + + getInfo(): MozimoProductRegister { + const formModel = this.form.value; + + return new MozimoProductRegister({ + product: this.info.product, + startDate: moment(formModel.startDate).format('DD-MMM-YYYY'), + finishDate: moment(formModel.finishDate).format('DD-MMM-YYYY'), + }); + } + + exportCsv() { + const headers = { + Date: 'date', + Opening: 'opening', + Received: 'received', + Sale: 'sale', + }; + + const d = JSON.parse(JSON.stringify(this.dataSource.data)).map((x: MozimoProductRegisterItem) => ({ + x, + })); + const csvData = new Blob([this.toCsv.toCsv(headers, d)], { + type: 'text/csv;charset=utf-8;', + }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(csvData); + link.setAttribute('download', 'mozimo-product-register.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + updateReceived($event: Event, row: MozimoProductRegisterItem) { + row.received = +($event.target as HTMLInputElement).value; + this.body.next(this.info.body); + } + + updateSale($event: Event, row: MozimoProductRegisterItem) { + row.sale = +($event.target as HTMLInputElement).value; + this.body.next(this.info.body); + } + + updateNc($event: Event, row: MozimoProductRegisterItem) { + row.nc = +($event.target as HTMLInputElement).value; + this.body.next(this.info.body); + } + + updateDisplay($event: Event, row: MozimoProductRegisterItem) { + const val = ($event.target as HTMLInputElement).value; + row.display = val === '' ? null : +val; + this.body.next(this.info.body); + } + + updateAgeing($event: Event, row: MozimoProductRegisterItem) { + const val = ($event.target as HTMLInputElement).value; + row.ageing = val === '' ? null : +val; + this.body.next(this.info.body); + } +} diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.spec.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.spec.ts new file mode 100644 index 00000000..d7750b7e --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; +import { ResolveFn } from '@angular/router'; + +import { MozimoProductRegister } from './mozimo-product-register'; +import { mozimoProductRegisterResolver } from './mozimo-product-register.resolver'; + +describe('mozimoProductRegisterResolver', () => { + const executeResolver: ResolveFn<MozimoProductRegister> = (...resolverParameters) => + TestBed.runInInjectionContext(() => mozimoProductRegisterResolver(...resolverParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeResolver).toBeTruthy(); + }); +}); diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.ts new file mode 100644 index 00000000..8a9b9ddd --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.resolver.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; + +import { MozimoProductRegister } from './mozimo-product-register'; +import { MozimoProductRegisterService } from './mozimo-product-register.service'; + +export const mozimoProductRegisterResolver: ResolveFn<MozimoProductRegister> = (route) => { + const id = route.paramMap.get('id'); + const startDate = route.queryParamMap.get('startDate') || null; + const finishDate = route.queryParamMap.get('finishDate') || null; + return inject(MozimoProductRegisterService).list(id, startDate, finishDate); +}; diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.routes.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.routes.ts new file mode 100644 index 00000000..d71dae18 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.routes.ts @@ -0,0 +1,34 @@ +import { Routes } from '@angular/router'; + +import { authGuard } from '../auth/auth-guard.service'; + +import { MozimoProductRegisterComponent } from './mozimo-product-register.component'; +import { mozimoProductRegisterResolver } from './mozimo-product-register.resolver'; + +export const routes: Routes = [ + { + path: '', + component: MozimoProductRegisterComponent, + canActivate: [authGuard], + data: { + permission: 'Ledger', + }, + resolve: { + info: mozimoProductRegisterResolver, + }, + runGuardsAndResolvers: 'always', + }, + { + path: ':id', + component: MozimoProductRegisterComponent, + canActivate: [authGuard], + data: { + // permission: 'Mozimo Product Register', + permission: 'Ledger', + }, + resolve: { + info: mozimoProductRegisterResolver, + }, + runGuardsAndResolvers: 'always', + }, +]; diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.service.spec.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.service.spec.ts new file mode 100644 index 00000000..d558638d --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.service.spec.ts @@ -0,0 +1,17 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; + +import { MozimoProductRegisterService } from './mozimo-product-register.service'; + +describe('MozimoProductRegisterService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [MozimoProductRegisterService, provideHttpClient(withInterceptorsFromDi())], + }); + }); + + it('should be created', inject([MozimoProductRegisterService], (service: MozimoProductRegisterService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.service.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.service.ts new file mode 100644 index 00000000..9c09a651 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.service.ts @@ -0,0 +1,55 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; + +import { MozimoProductRegister } from './mozimo-product-register'; + +const url = '/api/mozimo-product-register'; +const serviceName = 'MozimoProductRegisterService'; + +@Injectable({ + providedIn: 'root', +}) +export class MozimoProductRegisterService { + constructor( + private http: HttpClient, + private log: ErrorLoggerService, + ) {} + + list(id: string | null, startDate: string | null, finishDate: string | null): Observable<MozimoProductRegister> { + const listUrl = id === null ? url : `${url}/${id}`; + const options = { params: new HttpParams() }; + if (startDate !== null) { + options.params = options.params.set('s', startDate); + } + if (finishDate !== null) { + options.params = options.params.set('f', finishDate); + } + return this.http + .get<MozimoProductRegister>(listUrl, options) + .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<MozimoProductRegister>; + } + + save(mozimoProductRegister: MozimoProductRegister): Observable<MozimoProductRegister> { + return this.http + .post<MozimoProductRegister>(url, mozimoProductRegister) + .pipe(catchError(this.log.handleError(serviceName, 'save'))) as Observable<MozimoProductRegister>; + } + + post(date: string, costCentre: string): Observable<MozimoProductRegister> { + const options = { params: new HttpParams().set('d', costCentre) }; + return this.http + .post<MozimoProductRegister>(`${url}/${date}`, {}, options) + .pipe(catchError(this.log.handleError(serviceName, 'Post Voucher'))) as Observable<MozimoProductRegister>; + } + + delete(date: string, costCentre: string): Observable<MozimoProductRegister> { + const options = { params: new HttpParams().set('d', costCentre) }; + return this.http + .delete<MozimoProductRegister>(`${url}/${date}`, options) + .pipe(catchError(this.log.handleError(serviceName, 'Delete Voucher'))) as Observable<MozimoProductRegister>; + } +} diff --git a/overlord/src/app/mozimo-product-register/mozimo-product-register.ts b/overlord/src/app/mozimo-product-register/mozimo-product-register.ts new file mode 100644 index 00000000..98e14340 --- /dev/null +++ b/overlord/src/app/mozimo-product-register/mozimo-product-register.ts @@ -0,0 +1,18 @@ +import { Product } from '../core/product'; + +import { MozimoProductRegisterItem } from './mozimo-product-register-item'; + +export class MozimoProductRegister { + startDate: string; + finishDate: string; + product: Product; + body: MozimoProductRegisterItem[]; + + public constructor(init?: Partial<MozimoProductRegister>) { + this.startDate = ''; + this.finishDate = ''; + this.product = new Product(); + this.body = []; + Object.assign(this, init); + } +}