396 lines
14 KiB
Python
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)
|