188 lines
7.7 KiB
Python
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)
|