brewman/brewman/brewman/routers/issue.py

396 lines
14 KiB
Python

import uuid
from datetime import datetime
from decimal import Decimal
import brewman.schemas.input as schema_in
import brewman.schemas.voucher as output
from fastapi import APIRouter, Depends, File, HTTPException, Request, Security, status
from sqlalchemy import distinct, func, or_, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from ..core.security import get_current_active_user as get_user
from ..core.session import get_date, set_date
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.stock_keeping_unit import StockKeepingUnit
from ..models.validations import check_duplicate_batches, check_journals_are_valid
from ..models.voucher import Voucher
from ..models.voucher_type import VoucherType
from ..schemas.blank_voucher_info import BlankVoucherInfo
from ..schemas.cost_centre import CostCentreLink
from ..schemas.inventory import Inventory as InventorySchema
from ..schemas.user import UserToken
from . import get_lock_info
from .db_image import save_files, update_files
from .voucher import (
blank_voucher,
check_voucher_edit_allowed,
get_batch_quantity,
voucher_info,
)
router = APIRouter()
@router.post("", response_model=output.Voucher)
def save_route(
request: Request,
data: schema_in.IssueIn = Depends(schema_in.IssueIn.load_form),
i: list[bytes] = File(None),
t: list[bytes] = File(None),
user: UserToken = Security(get_user, scopes=["issue"]),
) -> output.Voucher:
try:
with SessionFuture() as db:
item, batch_consumed = save(data, user, db)
amount = save_inventories(item, data.inventories, batch_consumed, db)
check_duplicate_batches(item)
save_journals(item, data.source, data.destination, amount, db)
check_journals_are_valid(item)
save_files(item.id, i, t, db)
db.commit()
set_date(data.date_.strftime("%d-%b-%Y"), request.session)
info = voucher_info(item, db)
return info
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
def save(data: schema_in.IssueIn, user: UserToken, db: Session) -> tuple[Voucher, bool | None]:
product_accounts = (
select(Product.account_id)
.join(Product.skus)
.where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]))
)
account_types: list[int] = list(
db.execute(select(distinct(AccountBase.type_id)).where(AccountBase.id.in_(product_accounts))).scalars().all()
)
allowed, message = get_lock_info([data.date_], VoucherType[data.type_.replace(" ", "_").upper()], account_types, db)
if not allowed:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=message,
)
voucher = Voucher(
date_=data.date_,
narration=data.narration,
is_starred=data.is_starred,
user_id=user.id_,
voucher_type=VoucherType[data.type_.replace(" ", "_").upper()],
)
db.add(voucher)
if data.source.id_ == data.destination.id_:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Source cannot be the same as destination",
)
batch_consumed: bool | None
if data.source.id_ == CostCentre.cost_centre_purchase():
batch_consumed = True
elif data.destination.id_ == CostCentre.cost_centre_purchase():
batch_consumed = False
else:
batch_consumed = None
return voucher, batch_consumed
def save_inventories(
voucher: Voucher,
inventories: list[InventorySchema],
batch_consumed: bool | None,
db: Session,
) -> Decimal:
amount: Decimal = Decimal(0)
for data_item in inventories:
batch = db.execute(select(Batch).where(Batch.id == data_item.batch.id_)).scalar_one()
if batch_consumed and data_item.quantity > batch.quantity_remaining:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
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.sku.product.name} ({batch.sku.units}) "
f"was purchased on {batch.name.strftime('%d-%b-%Y')}",
)
if batch_consumed is None:
pass
elif batch_consumed:
batch.quantity_remaining -= data_item.quantity
else:
batch.quantity_remaining += data_item.quantity
item = Inventory(
id_=data_item.id_,
quantity=data_item.quantity,
rate=batch.rate,
tax=batch.tax,
discount=batch.discount,
batch=batch,
)
voucher.inventories.append(item)
db.add(item)
amount += round(item.amount, 2)
return amount
def save_journals(
voucher: Voucher,
source: schema_in.CostCentreLink,
destination: schema_in.CostCentreLink,
amount: Decimal,
db: Session,
) -> None:
s = Journal(
debit=-1,
account_id=AccountBase.all_purchases(),
amount=amount,
cost_centre_id=source.id_,
)
d = Journal(
debit=1,
account_id=AccountBase.all_purchases(),
amount=amount,
cost_centre_id=destination.id_,
)
voucher.journals.append(s)
db.add(s)
voucher.journals.append(d)
db.add(d)
@router.put("/{id_}", response_model=output.Voucher)
def update_route(
id_: uuid.UUID,
request: Request,
data: schema_in.IssueIn = Depends(schema_in.IssueIn.load_form),
i: list[bytes] = File(None),
t: list[bytes] = File(None),
user: UserToken = Security(get_user, scopes=["issue"]),
) -> output.Voucher:
try:
with SessionFuture() as db:
item, batch_consumed = update_voucher(id_, data, user, db)
amount = update_inventories(item, data.inventories, batch_consumed, db)
check_duplicate_batches(item)
update_journals(item, data.source, data.destination, amount)
check_journals_are_valid(item)
update_files(item.id, data.files, i, t, db)
db.commit()
set_date(data.date_.strftime("%d-%b-%Y"), request.session)
# item: Voucher = db.execute(select(Voucher).where(Voucher.id == item.id)).scalar_one()
return voucher_info(item, db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
def update_voucher(
id_: uuid.UUID, data: schema_in.IssueIn, user: UserToken, db: Session
) -> tuple[Voucher, bool | None]:
voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one()
product_accounts = (
select(Product.account_id)
.join(Product.skus)
.where(StockKeepingUnit.id.in_([i.batch.sku.id_ for i in data.inventories]))
)
account_types: list[int] = list(
db.execute(
select(distinct(AccountBase.type_id)).where(
or_(
AccountBase.id.in_([vj.account_id for vj in voucher.journals]),
AccountBase.id.in_(product_accounts),
)
)
)
.scalars()
.all()
)
allowed, message = get_lock_info([voucher.date_, data.date_], voucher.voucher_type, account_types, db)
if not allowed:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=message,
)
check_voucher_edit_allowed(voucher, user)
voucher.date_ = data.date_
voucher.is_starred = data.is_starred
voucher.narration = data.narration
voucher.user_id = user.id_
voucher.posted = False
voucher.last_edit_date = datetime.utcnow()
for item in voucher.journals:
if item.debit == 1:
destination = item.cost_centre_id
else:
source = item.cost_centre_id
old_batch_consumed: bool | None
if source == CostCentre.cost_centre_purchase():
old_batch_consumed = True
elif destination == CostCentre.cost_centre_purchase():
old_batch_consumed = False
else:
old_batch_consumed = None
new_batch_consumed: bool | None
if data.source.id_ == CostCentre.cost_centre_purchase():
new_batch_consumed = True
elif data.destination.id_ == CostCentre.cost_centre_purchase():
new_batch_consumed = False
else:
new_batch_consumed = None
if new_batch_consumed != old_batch_consumed:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Purchase cost centre cannot be changed",
)
return voucher, new_batch_consumed
def update_inventories(
voucher: Voucher,
inventories: list[InventorySchema],
batch_consumed: bool | None,
db: Session,
) -> Decimal:
amount: Decimal = Decimal(0)
for it in range(len(voucher.inventories), 0, -1):
item = voucher.inventories[it - 1]
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 and d.batch.id_ == item.batch.id), None
)
if index is not None:
new_inventory = inventories.pop(index)
if batch_consumed and new_inventory.quantity > batch_quantity:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
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 {batch.sku.product.name} ({batch.sku.units}) was purchased after the issue date",
)
if batch_consumed is None:
pass
elif batch_consumed:
item.batch.quantity_remaining = batch_quantity - new_inventory.quantity
else:
item.batch.quantity_remaining = batch_quantity + new_inventory.quantity
item.quantity = new_inventory.quantity
item.rate = batch.rate
item.tax = batch.tax
item.discount = batch.discount
amount += round(item.amount, 2)
else:
if batch_consumed is None:
pass
elif batch_consumed:
item.batch.quantity_remaining = batch_quantity
else:
if batch_quantity < item.quantity:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
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
db.delete(item)
voucher.inventories.remove(item)
amount += save_inventories(voucher, inventories, batch_consumed, db)
return amount
def update_journals(
voucher: Voucher,
source: schema_in.CostCentreLink,
destination: schema_in.CostCentreLink,
amount: Decimal,
) -> None:
for i in range(len(voucher.journals), 0, -1):
item = voucher.journals[i - 1]
if item.debit == -1:
item.cost_centre_id = source.id_
item.amount = amount
else:
item.cost_centre_id = destination.id_
item.amount = amount
def refresh_voucher(id_: uuid.UUID, batch_id: uuid.UUID, db: Session) -> None:
try:
db.execute(update(Voucher).where(Voucher.id == id_).values(last_edit_date=datetime.utcnow()))
batch = db.execute(select(Batch).where(Batch.id == batch_id)).scalar_one()
db.execute(
update(Inventory)
.where(Inventory.voucher_id == id_, Inventory.batch_id == batch_id)
.values(rate=batch.rate, tax=batch.tax, discount=batch.discount)
)
amount = round(
db.execute(select(func.sum(Inventory.amount)).where(Inventory.voucher_id == id_)).scalar_one(), 2
)
db.execute(update(Journal).where(Journal.voucher_id == id_).values(amount=amount))
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
@router.get("/{id_}", response_model=output.Voucher)
def get_id(
id_: uuid.UUID,
user: UserToken = Security(get_user, scopes=["issue"]),
) -> output.Voucher:
try:
with SessionFuture() as db:
item: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one()
return voucher_info(item, db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
@router.get("", response_model=output.Voucher)
def show_blank(
request: Request,
date: str | None = None,
source: uuid.UUID | None = None,
destination: uuid.UUID | None = None,
user: UserToken = Security(get_user, scopes=["issue"]),
) -> output.Voucher:
date_ = date or get_date(request.session)
additional_info = BlankVoucherInfo(date_=date_, type_=VoucherType.ISSUE) # type: ignore[arg-type]
if source:
additional_info.source = CostCentreLink(id_=source, name="")
if destination:
additional_info.destination = CostCentreLink(id_=destination, name="")
with SessionFuture() as db:
return blank_voucher(additional_info, db)