Files
Refactored-App/backend/apps/operations/services.py
2026-04-01 03:20:54 +02:00

188 lines
7.7 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from django.db import transaction
from django.utils import timezone
from django.utils.dateparse import parse_date
from apps.core.models import Category, Product
from apps.operations.models import Invoice, InvoiceCategory, InvoiceLineItem
TWOPLACES = Decimal("0.01")
def _decimal(value: object, default: str = "0") -> Decimal:
if value in (None, ""):
return Decimal(default)
return Decimal(str(value))
def _round_money(value: Decimal) -> Decimal:
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
@dataclass
class InvoiceLineInput:
product_id: int
quantity: Decimal
unit_price: Decimal
def build_invoice_payload(invoice: Invoice) -> dict:
return {
"id": invoice.id,
"legacy_id": invoice.legacy_id,
"business_id": invoice.business_id,
"business_name": invoice.business.name if invoice.business_id else None,
"vendor_id": invoice.vendor_id,
"vendor_name": invoice.vendor.name,
"invoice_number": invoice.invoice_number,
"invoice_date": invoice.invoice_date.isoformat() if invoice.invoice_date else None,
"order_date": invoice.order_date.isoformat() if invoice.order_date else None,
"payment_status": invoice.payment_status,
"paid_date": invoice.paid_date.isoformat() if invoice.paid_date else None,
"due_date": invoice.due_date.isoformat() if invoice.due_date else None,
"subtotal": float(invoice.subtotal),
"discount_pct": float(invoice.discount_pct),
"discount_amount": float(invoice.discount_amount),
"total_after_discount": float(invoice.total_after_discount),
"vat_amount": float(invoice.vat_amount),
"gross_total": float(invoice.gross_total),
"currency": invoice.currency,
"goods_received_status": invoice.goods_received_status,
"goods_date": invoice.goods_date.isoformat() if invoice.goods_date else None,
"notes": invoice.notes,
"inventory_updated": invoice.inventory_updated,
"vat_exempt": invoice.vat_exempt,
"category_ids": list(invoice.category_links.values_list("category_id", flat=True)),
"line_items": [
{
"id": line.id,
"product_id": line.product_id,
"product_name": line.product.name,
"quantity": float(line.quantity),
"unit_price": float(line.unit_price),
"total_price": float(line.total_price),
"vat_rate": float(line.vat_rate),
"vat_amount": float(line.vat_amount),
"line_order": line.line_order,
}
for line in invoice.line_items.all().order_by("line_order")
],
}
def _resolve_lines(line_items: list[dict]) -> tuple[list[tuple[Product, InvoiceLineInput]], Decimal, Decimal]:
resolved: list[tuple[Product, InvoiceLineInput]] = []
subtotal = Decimal("0")
total_vat = Decimal("0")
product_ids = [int(item["product_id"]) for item in line_items]
products = Product.objects.in_bulk(product_ids)
for item in line_items:
product_id = int(item["product_id"])
product = products.get(product_id)
if product is None:
raise ValueError(f"Unknown product_id: {product_id}")
line = InvoiceLineInput(
product_id=product_id,
quantity=_decimal(item.get("quantity"), "0"),
unit_price=_decimal(item.get("unit_price"), "0"),
)
line_total = _round_money(line.quantity * line.unit_price)
line_vat = _round_money(line_total * _decimal(product.vat_rate) / Decimal("100"))
subtotal += line_total
total_vat += line_vat
resolved.append((product, line))
return resolved, _round_money(subtotal), _round_money(total_vat)
def _persist_invoice_payload(invoice: Invoice, data: dict, *, is_create: bool) -> Invoice:
line_items = data.get("line_items") or []
if not line_items:
raise ValueError("At least one line item is required")
resolved_lines, subtotal, computed_vat = _resolve_lines(line_items)
discount_pct = _decimal(data.get("discount_pct"), "0")
discount_amount = _decimal(data.get("discount_amount"), "0")
if discount_pct > 0 and discount_amount == 0:
discount_amount = _round_money(subtotal * discount_pct / Decimal("100"))
total_after_discount = _round_money(subtotal - discount_amount)
vat_exempt = bool(data.get("vat_exempt", False))
total_vat = Decimal("0") if vat_exempt else computed_vat
gross_total = _round_money(total_after_discount + total_vat)
payment_status = data.get("payment_status") or "unpaid"
goods_received_status = data.get("goods_received_status") or "not_received"
today = timezone.localdate()
invoice.business_id = data.get("business_id") or None
invoice.vendor_id = int(data["vendor_id"])
invoice.vendor_not_configured = bool(data.get("vendor_not_configured", False))
invoice.vendor_config_note = data.get("vendor_config_note", "")
invoice.invoice_number = data.get("invoice_number", "")
invoice.invoice_date = parse_date(data["invoice_date"]) if data.get("invoice_date") else None
invoice.order_date = parse_date(data["order_date"]) if data.get("order_date") else None
if is_create:
invoice.entered_at = timezone.now()
invoice.payment_status = payment_status
invoice.paid_date = parse_date(data["paid_date"]) if data.get("paid_date") else (today if payment_status == "paid" else None)
invoice.due_date = parse_date(data["due_date"]) if data.get("due_date") else None
invoice.subtotal = subtotal
invoice.discount_pct = discount_pct
invoice.discount_amount = discount_amount
invoice.total_after_discount = total_after_discount
invoice.vat_amount = total_vat
invoice.gross_total = gross_total
invoice.currency = data.get("currency", "CZK")
invoice.goods_received_status = goods_received_status
invoice.goods_date = (
parse_date(data["goods_date"]) if data.get("goods_date") else (today if goods_received_status == "received" else None)
)
invoice.notes = data.get("notes", "")
invoice.vat_exempt = vat_exempt
invoice.save()
invoice_lines: list[InvoiceLineItem] = []
for index, (product, line) in enumerate(resolved_lines):
total_price = _round_money(line.quantity * line.unit_price)
vat_amount = Decimal("0") if vat_exempt else _round_money(total_price * _decimal(product.vat_rate) / Decimal("100"))
invoice_lines.append(
InvoiceLineItem(
invoice=invoice,
product=product,
quantity=line.quantity,
unit_price=line.unit_price,
total_price=total_price,
vat_rate=product.vat_rate,
vat_amount=vat_amount,
line_order=index,
)
)
invoice.line_items.all().delete()
InvoiceLineItem.objects.bulk_create(invoice_lines)
invoice.category_links.all().delete()
if category_ids := data.get("category_ids"):
categories = Category.objects.in_bulk(category_ids)
links = [
InvoiceCategory(invoice=invoice, category=category)
for category_id in category_ids
if (category := categories.get(int(category_id))) is not None
]
InvoiceCategory.objects.bulk_create(links, ignore_conflicts=True)
return invoice
@transaction.atomic
def create_invoice_from_payload(data: dict) -> Invoice:
return _persist_invoice_payload(Invoice(), data, is_create=True)
@transaction.atomic
def update_invoice_from_payload(invoice: Invoice, data: dict) -> Invoice:
return _persist_invoice_payload(invoice, data, is_create=False)