382 lines
16 KiB
Python
382 lines
16 KiB
Python
import csv
|
|
import math
|
|
import time
|
|
import traceback
|
|
from datetime import datetime
|
|
from sqlite3 import OperationalError
|
|
|
|
from django.db import transaction, OperationalError
|
|
from django.db.backends import sqlite3
|
|
from django.db.models import F
|
|
from django.db.models.aggregates import Sum
|
|
from django.db.models.query_utils import Q
|
|
from threading import Thread
|
|
|
|
import numpy
|
|
|
|
from main.models import Consumption, Order, Inventory, Product, OutgoingInvoice, \
|
|
OutgoingInvoiceProductPosition, OutgoingInvoiceProductUserPosition, ProductInventory
|
|
from tallybill.tally_settings import PROFIT_FACTOR, PROFIT_FIXED_CENTS
|
|
|
|
|
|
def _get_total_consumption_until(product, date):
|
|
# total consumption of all recorded data, including loss
|
|
inventory_count = 0
|
|
try:
|
|
inventory_count = ProductInventory.objects.get(inventory__date=date, product=product).count
|
|
except ProductInventory.DoesNotExist as e:
|
|
pass
|
|
orders_until = (Order.objects.filter(product=product, incoming_invoice__date__lte=date)
|
|
.aggregate(Sum("count"))["count__sum"] or 0)
|
|
return orders_until - inventory_count
|
|
|
|
|
|
class BillingPeriod(object):
|
|
|
|
def __init__(self, inventory, previous_inventory=None):
|
|
# if inventory is None: nothing to inventory - a total of zero products are assumed
|
|
self._inventory = inventory
|
|
self._previous_inventory = previous_inventory
|
|
if inventory is not None and previous_inventory is None:
|
|
previous_inventories = Inventory.objects.filter(date__lt=inventory.date).order_by("-date")
|
|
if previous_inventories.count() > 0:
|
|
self._previous_inventory = previous_inventories.first()
|
|
|
|
@property
|
|
def inventory(self):
|
|
return self._inventory
|
|
|
|
@property
|
|
def products(self):
|
|
query = Q(productinventory__inventory=self._inventory)
|
|
if self._previous_inventory is not None:
|
|
query |= Q(productinventory__inventory=self._previous_inventory)
|
|
return [ProductInPeriod(self, p) for p in Product.objects.filter(query).distinct()]
|
|
|
|
@property
|
|
def date_from(self):
|
|
if self._previous_inventory:
|
|
return self._previous_inventory.date
|
|
# just some random time long ago
|
|
return None
|
|
|
|
@property
|
|
def date_until(self):
|
|
return self._inventory.date
|
|
|
|
@property
|
|
def previous_billing_period(self):
|
|
if self._previous_inventory:
|
|
return BillingPeriod(self._previous_inventory)
|
|
return None
|
|
|
|
@staticmethod
|
|
def all():
|
|
return (BillingPeriod(inventory) for inventory in Inventory.objects.all())
|
|
|
|
@property
|
|
def invoices(self):
|
|
return OutgoingInvoice.objects_all.filter(inventory=self._inventory).order_by("date")
|
|
|
|
@transaction.atomic
|
|
def recalculate_temporary_invoices(self):
|
|
# there should always only be one temporary invoice
|
|
try:
|
|
invoice = self.invoices.get(is_frozen=False)
|
|
except OutgoingInvoice.DoesNotExist:
|
|
try:
|
|
correction_of = self._inventory.outgoinginvoice_set.get()
|
|
except OutgoingInvoice.DoesNotExist:
|
|
correction_of = None
|
|
invoice = OutgoingInvoice(inventory=self._inventory, correction_of=correction_of)
|
|
invoice.save()
|
|
invoice.outgoinginvoiceproductposition_set.all().delete()
|
|
|
|
# recalculate shit
|
|
invoice_total = 0
|
|
invoice_profit = 0
|
|
for product in self.products:
|
|
pos_loss_factor = float(max(1.0, product.get_loss_factor()))
|
|
avg_price = product.get_avg_price_for_consumed()
|
|
if pos_loss_factor == math.inf:
|
|
each_cents = 0
|
|
each_no_profit = 0
|
|
else:
|
|
each_cents = int(avg_price * pos_loss_factor * PROFIT_FACTOR + PROFIT_FIXED_CENTS)
|
|
each_no_profit = int(avg_price * pos_loss_factor)
|
|
|
|
position = OutgoingInvoiceProductPosition.objects.create(
|
|
product=product.product, invoice=invoice,
|
|
loss=product.get_loss(), price_each=each_cents, total=0, profit=0)
|
|
product_total = 0
|
|
for user, count in product.get_user_consumptions():
|
|
product_total += count
|
|
OutgoingInvoiceProductUserPosition.objects.create(user_id=user, count=count, productinvoice=position)
|
|
invoice_total += product_total * each_cents
|
|
invoice_profit += product_total * each_cents - product_total * each_no_profit
|
|
position.total = product_total * each_cents
|
|
position.profit = product_total * each_cents - product_total * each_no_profit
|
|
position.save()
|
|
|
|
invoice.total = invoice_total
|
|
invoice.profit = invoice_profit
|
|
invoice.save()
|
|
self._inventory.may_have_changed = False
|
|
self._inventory.save(fast=True)
|
|
|
|
|
|
class ProductInPeriod(object):
|
|
|
|
def __init__(self, billing_period, product):
|
|
self._billing_period = billing_period
|
|
self._product = product
|
|
|
|
@property
|
|
def product(self):
|
|
return self._product
|
|
|
|
@property
|
|
def billing_period(self):
|
|
return self._billing_period
|
|
|
|
def get_total_orders(self):
|
|
q = Q(incoming_invoice__date__lte=self._billing_period.date_until, product=self._product)
|
|
if self._billing_period.date_from is not None:
|
|
q &= Q(incoming_invoice__date__gt=self._billing_period.date_from)
|
|
return Order.objects.filter(q).aggregate(Sum("count"))["count__sum"] or 0
|
|
|
|
@staticmethod
|
|
def get_total_orders_table(inventories_qs, product_qs):
|
|
inventories = inventories_qs.values_list("pk", "date")
|
|
product_ids = list(product_qs.values_list("pk", flat=True))
|
|
orders = (Order.objects.values("incoming_invoice__date", "product")
|
|
.annotate(count=Sum("count")).order_by("incoming_invoice__date")
|
|
.values_list("incoming_invoice__date", "product", "count").iterator())
|
|
table = numpy.zeros((inventories.count(), Product.objects.count()), dtype=int)
|
|
|
|
try:
|
|
order_date, order_product_id, count = next(orders)
|
|
for i, (pk, date) in enumerate(inventories):
|
|
while order_date <= date:
|
|
table[i][product_ids.index(order_product_id)] += count
|
|
order_date, order_product_id, count = next(orders)
|
|
except StopIteration:
|
|
pass
|
|
return table
|
|
|
|
@staticmethod
|
|
def get_product_inventory_count_table(inventories_qs, product_qs):
|
|
# TODO what if inventories_qs is not all inventories ordered by date... fail
|
|
inventories = list(inventories_qs.values_list("pk", flat=True))
|
|
product_ids = list(product_qs.values_list("pk", flat=True))
|
|
product_inventories = ProductInventory.objects.values_list("inventory", "product", "count")
|
|
table = numpy.zeros((len(inventories), len(product_ids)), dtype=int)
|
|
|
|
for inventory, product, count in product_inventories:
|
|
table[inventories.index(inventory)][product_ids.index(product)] += count
|
|
return table, inventories, product_ids
|
|
|
|
@classmethod
|
|
def get_real_consumption_list(cls, inventories_qs, product_qs):
|
|
# TODO what if inventories_qs is not all inventories ordered by date... fail
|
|
product_inventory_table, inventory_ids, product_ids = cls.get_product_inventory_count_table(inventories_qs, product_qs)
|
|
product_order_table = cls.get_total_orders_table(inventories_qs, product_qs)
|
|
|
|
result_table = numpy.zeros(product_order_table.shape, dtype=int)
|
|
for inventory_idx in range(len(inventory_ids)):
|
|
for product_idx in range(len(product_ids)):
|
|
if inventory_idx > 0:
|
|
previous_inventory_count = product_inventory_table[inventory_idx - 1, product_idx]
|
|
else:
|
|
previous_inventory_count = 0
|
|
result_table[inventory_idx, product_idx] = (previous_inventory_count +
|
|
product_order_table[inventory_idx, product_idx] -
|
|
product_inventory_table[inventory_idx, product_idx])
|
|
return result_table
|
|
|
|
@staticmethod
|
|
def get_listed_consumptions_table(inventories_qs, product_qs, consumptions=None):
|
|
# TODO what if inventories_qs is not all inventories ordered by date... fail
|
|
inventories = inventories_qs.values_list("pk", "date").order_by("date")
|
|
product_ids = list(product_qs.values_list("pk", flat=True))
|
|
|
|
consumptions_iter = ((Consumption.objects if consumptions is None else consumptions)
|
|
.values_list("date", "product_id", "count").order_by("date").iterator())
|
|
|
|
table = numpy.zeros((inventories.count(), len(product_ids)), dtype=int)
|
|
try:
|
|
consumption_date, product_id, count = next(consumptions_iter)
|
|
for i, (pk, date) in enumerate(inventories):
|
|
while consumption_date <= date:
|
|
table[i][product_ids.index(product_id)] += count
|
|
consumption_date, product_id, count = next(consumptions_iter)
|
|
except StopIteration:
|
|
pass
|
|
return table
|
|
|
|
def get_real_consumption(self):
|
|
assert isinstance(self._billing_period.inventory, Inventory)
|
|
previous_inventory_count = 0
|
|
if self._billing_period.previous_billing_period is not None:
|
|
try:
|
|
previous_inventory_count = (self._billing_period.previous_billing_period.inventory
|
|
.productinventory_set.get(product=self._product).count)
|
|
except ProductInventory.DoesNotExist as e:
|
|
pass
|
|
if self._billing_period.date_from is None:
|
|
return 0
|
|
|
|
inventory_count = 0
|
|
try:
|
|
inventory_count = self._billing_period.inventory.productinventory_set.get(product=self._product).count
|
|
except ProductInventory.DoesNotExist as e:
|
|
pass
|
|
|
|
consumption = (previous_inventory_count - inventory_count + self.get_total_orders())
|
|
|
|
# ------------------------ validating ---------------------------------
|
|
# consumption2 = (_get_total_consumption_until(self._product, self._billing_period.date_until) -
|
|
# _get_total_consumption_until(self._product, self._billing_period.date_from))
|
|
# assert consumption == consumption2, (consumption, consumption2)
|
|
# ------------------------- end valdiating -----------------------------
|
|
|
|
return consumption
|
|
|
|
def get_listed_consumptions(self):
|
|
assert isinstance(self._product, Product)
|
|
if self._billing_period.date_from is None:
|
|
return 0
|
|
return (self._product.consumption_set.filter(date__gt=self._billing_period.date_from,
|
|
date__lte=self._billing_period.date_until)
|
|
.aggregate(Sum("count"))["count__sum"] or 0)
|
|
|
|
def get_avg_price_for_consumed(self):
|
|
# avg price of products that where consumed
|
|
# i assumed a product really is a single product
|
|
# TODO bissl umstaendlich umgesetzt
|
|
if self._billing_period.date_from is None:
|
|
return 0
|
|
consumed_before_period = _get_total_consumption_until(self._product, self._billing_period.date_from)
|
|
|
|
real_consumptions = max(self.get_real_consumption(), 0)
|
|
|
|
orders = self._product.order_set.order_by("incoming_invoice__date").values_list("count", "each_cents")
|
|
if orders.count() == 0:
|
|
quantities, prices = [], []
|
|
else:
|
|
quantities, prices = zip(*orders)
|
|
quantities, prices = list(quantities), list(prices)
|
|
still_there = 0
|
|
# filter out all already billed quantities
|
|
for i in range(len(quantities)):
|
|
v = min(consumed_before_period, quantities[i])
|
|
if consumed_before_period > 0:
|
|
consumed_before_period -= v
|
|
quantities[i] -= v
|
|
if consumed_before_period == 0:
|
|
still_there += max(quantities[i] - real_consumptions, 0)
|
|
quantities[i] -= max(quantities[i] - real_consumptions, 0)
|
|
real_consumptions -= v
|
|
if sum(quantities) == 0:
|
|
return 0
|
|
return sum([quantities[i] * prices[i] for i in range(len(prices))]) / sum(quantities)
|
|
|
|
def get_loss_factor(self):
|
|
# return factor to compensate loss
|
|
real_consumptions = self.get_real_consumption()
|
|
listed_consumptions = self.get_listed_consumptions()
|
|
if listed_consumptions == 0:
|
|
# loss cannot be compensated, since (apparently) nobody consumed nothing
|
|
# (even though real_consumption may indicate consumptions)
|
|
if real_consumptions != 0:
|
|
return math.inf
|
|
return 1.
|
|
return real_consumptions / listed_consumptions
|
|
|
|
def get_loss(self):
|
|
lf = self.get_loss_factor()
|
|
if lf > 0:
|
|
return - (1. - lf) * 100.
|
|
else:
|
|
# negative real consumptions
|
|
# loss not really interpretable
|
|
# TODO: better options?
|
|
return -math.inf
|
|
|
|
def get_user_consumptions(self, user=None):
|
|
if self._billing_period.date_from is None:
|
|
return []
|
|
consumptions = Consumption.objects.filter(date__gt=self._billing_period.date_from,
|
|
date__lte=self._billing_period.date_until, product=self._product)
|
|
if user is not None:
|
|
consumptions = consumptions.filter(user=user)
|
|
|
|
return (consumptions.values("user").annotate(consumed=Sum("count"))
|
|
.order_by("user").values_list("user", "consumed"))
|
|
|
|
|
|
class RecalculateThread(Thread):
|
|
|
|
def run(self):
|
|
print("Started Recalculation Thread.")
|
|
while True:
|
|
try:
|
|
inventories = Inventory.objects.filter(may_have_changed=True)
|
|
for inventory in inventories:
|
|
print("Recalculating: %s" % str(inventory))
|
|
BillingPeriod(inventory).recalculate_temporary_invoices()
|
|
except OperationalError:
|
|
pass
|
|
except:
|
|
traceback.print_exc()
|
|
time.sleep(1.0)
|
|
|
|
|
|
def outgoing_to_csv(outgoing, f, difference=False):
|
|
|
|
def get_user_sum(outgoing_invoice):
|
|
if difference:
|
|
values = OutgoingInvoiceProductUserPosition.objects \
|
|
.filter(productinvoice__invoice=outgoing_invoice).values_list("user__pk", "user__username") \
|
|
.annotate(total=Sum(F("count") * F("productinvoice__price_each"))).order_by("user__username")
|
|
return dict(((x, (y, z)) for x, y, z in values))
|
|
else:
|
|
return {}
|
|
|
|
def get_diff_positions():
|
|
current_positions = get_user_sum(outgoing)
|
|
|
|
if outgoing.correction_of is not None:
|
|
previous_positions = get_user_sum(outgoing.correction_of)
|
|
else:
|
|
previous_positions = {}
|
|
|
|
updated_positions = [((current_positions[k][0], current_positions[k][1] - previous_positions[k][1])
|
|
if k in previous_positions else current_positions[k])
|
|
for k in current_positions]
|
|
removed_positions = [(previous_positions[k][0], -previous_positions[k][1])
|
|
for k in set(previous_positions.keys()).difference(set(current_positions))]
|
|
|
|
return updated_positions + removed_positions
|
|
|
|
positions = get_diff_positions()
|
|
|
|
writer = csv.writer(f)
|
|
writer.writerow(["Datum", datetime.strftime(outgoing.inventory.date, "%d.%m.%Y")])
|
|
writer.writerow(["Beschreibung", datetime.strftime(outgoing.inventory.date, "Getränkeabrechnung %B")])
|
|
writer.writerow([])
|
|
writer.writerow(["Account", "Wert (Positiv = belastend)", "Notizen"])
|
|
|
|
sum = 0
|
|
for name, amount in positions:
|
|
writer.writerow([name, amount / 100.0])
|
|
sum -= amount
|
|
writer.writerow(["Getränke.Erträge", sum / 100.0])
|
|
print(outgoing.inventory.date, sum / 100.)
|
|
|
|
# TODO: don't do this here in production mode...
|
|
recalculate_thread = RecalculateThread()
|
|
recalculate_thread.daemon = True
|
|
recalculate_thread.start()
|
|
|