Skip to content
This repository has been archived by the owner on Jul 18, 2024. It is now read-only.

Commit

Permalink
feat(datev): add personio to datev converter and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
zyv committed Nov 29, 2023
1 parent b32fbea commit 2deb9db
Show file tree
Hide file tree
Showing 6 changed files with 404 additions and 2 deletions.
2 changes: 2 additions & 0 deletions sepacetamol/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

<main role="main" class="container">

<h1>SEPAcetamol</h1>

{% block content %}
{% endblock %}

Expand Down
43 changes: 43 additions & 0 deletions sepacetamol/templates/datev.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{% extends "base.html" %}

{% load static %}


{% block content %}
<h2>Personio accounting</h2>

{% for message in messages %}
<div class="alert alert-{{ message.level_tag }}" role="alert">{{ message }}</div>
{% endfor %}

<form class="mb-3" method="post" enctype="multipart/form-data">

{% csrf_token %}

<div class="row mb-3">
<label for="consultant-number" class="col-sm-2 col-form-label">Consultant number</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="consultant-number" name="consultant-number"
value="{{ datev_settings.consultant_number }}" placeholder="843719" required>
</div>
</div>

<div class="row mb-3">
<label for="client-number" class="col-sm-2 col-form-label">Client number</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="client-number" name="client-number"
value="{{ datev_settings.client_number }}" placeholder="10487" required>
</div>
</div>

<div class="col-auto mb-3">
<input type="file" class="form-control" id="personio-file" name="personio-file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required>
</div>

<div class="col-auto">
<button type="submit" class="btn btn-primary">Convert file</button>
</div>

</form>
{% endblock %}
4 changes: 3 additions & 1 deletion sepacetamol/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
{% load static %}

{% block content %}
<h1>SEPAcetamol</h1>
<div class="alert alert-info" role="alert">
For Personio → DATEV converter, <a href="{% url "personio-datev" %}">follow along</a>.
</div>

<p>
Download the <a href="{% static "sepa-xml-template.xlsx" %}">wire table template</a>,
Expand Down
3 changes: 2 additions & 1 deletion sepacetamol/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.urls import path

from .views import sepa
from .views import datev, sepa

urlpatterns = [
path("", sepa.index, name="index"),
path("generate/", sepa.generate, name="generate"),
path("personio-datev/", datev.index, name="personio-datev"),
]
287 changes: 287 additions & 0 deletions sepacetamol/views/datev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import calendar
import csv
import re
from datetime import date, datetime
from io import StringIO
from typing import Annotated, Literal, Optional

from django.contrib import messages as message
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import render
from django.utils.encoding import smart_str
from openpyxl.reader.excel import load_workbook
from pydantic import BaseModel, Field, field_validator


def float_to_german(value: float) -> str:
return f"{value:.2f}".replace(".", ",")


def unquote_empty_csv_strings(value: str) -> str:
while ';"";' in value:
value = value.replace(';"";', ";;")
result = value.removesuffix(';""')
return result if result == value else result + ";"


def date_to_datev(d: date) -> str:
return d.strftime("%Y%m%d")


PositiveInt = Annotated[int, Field(gt=0)]


class DatevSettings(BaseModel):
consultant_number: PositiveInt
client_numer: PositiveInt

class Config:
populate_by_name = True
validate_default = True
validate_assignment = True
frozen = True


class DatevModel(BaseModel):
def model_dump_csv(self) -> str:
output = StringIO()
datev_writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=";")
datev_writer.writerow(self.model_dump().values())
return "\n".join(unquote_empty_csv_strings(line) for line in output.getvalue().splitlines())

class Config:
populate_by_name = True
validate_default = True
validate_assignment = True
frozen = True


class DatevHeader(DatevModel):
flag: Literal["EXTF", "DTVF"]
version_numer: Literal[700] = 700
format_category: Literal[16, 20, 21, 46, 48, 65]
format_name: Literal[
"Buchungsstapel",
"Wiederkehrende Buchungen",
"Debitoren/Kreditoren",
"Sachkontenbeschriftungen",
"Zahlungsbedingungen",
"Diverse Adressen",
]
format_version: Literal[2, 4, 5, 9, 12]
created_on: Optional[str] = Field(
default=None,
pattern=(
r"^20[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[1-2][0-9]|3[0-1])"
r"(2[0-3]|[01][0-9])[0-5][0-9][0-5][0-9][0-9][0-9][0-9]$"
),
)
reserved_07: str = Field(default="", max_length=0)
reserved_08: Optional[str] = Field(default=None, pattern=r"^\w{1,2}$")
reserved_09: Optional[str] = Field(default=None, pattern=r"^\w{1,25}$")
reserved_10: Optional[str] = Field(default=None, pattern=r"^\w{1,25}$")
consultant_number: int = Field(ge=1001, le=9999999)
client_numer: int = Field(ge=1, le=99999)
business_year_start: int
gl_account_length: int = Field(ge=4, le=8)
date_from: int
date_till: int
designation: str = Field(pattern=r"^[\w.\-/ ]{0,30}$")
initials: Optional[str] = Field(default=None, pattern=r"^([A-Z]{2}){1,2}$")
record_type: Optional[Literal[1, 2]] = None
accounting_reason: Optional[Literal[0, 30, 40, 50, 64]] = None
locking: Literal[0, 1]
currency_code: str = Field(default="EUR", pattern=r"^[A-Z]{3}$")
reserved_23: str = Field(default="", max_length=0)
derivatives_flag: str = Field(default="", max_length=0)
reserved_25: str = Field(default="", max_length=0)
reserved_26: str = Field(default="", max_length=0)
gl_chart_of_accounts: str = Field(pattern=r"^(\d{2}){0,2}$")
industry_solution_id: Optional[int] = Field(default=None, ge=0, le=9999)
reserved_29: str = Field(default="", max_length=0)
reserved_30: str = Field(default="", max_length=0)
application_information: str = Field(default="", max_length=16)

@field_validator("business_year_start", "date_from", "date_till")
def date_pattern(cls, v):
if re.match(r"^20[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[1-2][0-9]|3[0-1])$", str(v)) is None:
raise ValueError("must match date pattern")
return v


class DatevBooking(DatevModel):
umsatz: str = Field(
alias="Umsatz",
pattern=r"^\d{1,10}[,]\d{2}$",
description="Umsatz/Betrag für den Datensatz, z.B.: 1234567890,12 .Betrag muss positiv sein.",
)
soll_haben_kz: Literal["S", "H"] = Field(
alias="Soll-/Haben-Kennzeichen",
description="Soll-/Haben-Kennzeichnung bezieht sich auf das Feld #7 Konto",
)
wkz_umsatz: Optional[str] = Field(
alias="WKZ Umsatz",
default=None,
pattern=r"^[A-Z]{3}$",
description="ISO-Code der Währung",
)
kurs: Optional[str] = Field(
alias="Kurs",
default=None,
pattern=r"^[1-9]\d{0,3}[,]\d{2,6}$",
description="Wenn Umsatz in Fremdwährung bei #1 angegeben wird #4, #5 und #6 sind zu übergeben, z.B.: 12,34",
)
basisumsatz: Optional[str] = Field(
alias="Basisumsatz",
default=None,
pattern=r"^\d{1,10}[,]\d{2}$",
description="Siehe #4, z.B.: 1234567890,12",
)
wkz_basisumsatz: Optional[str] = Field(
alias="WKZ Basisumsatz",
default=None,
pattern=r"^[A-Z]{3}$",
description="Siehe #4",
)
konto: int = Field(alias="Konto", description="Sach- oder Personenkonto, z.B. 8400")
gegenkonto: int = Field(alias="Gegenkonto (ohne BU-Schlüssel)", description="Sach- oder Personenkonto, z.B. 70000")
bu_schluessel: Optional[int] = Field(
alias="BU-Schlüssel",
default=None,
ge=1000,
le=9999,
description="Steuerungskennzeichen zur Abbildung verschiedener Funktionen/Sachverhalte",
)
belegdatum: str = Field(
alias="Belegdatum",
pattern=r"^\d{4}$",
description="Format: TTMM, z.B. 0105, Das Jahr wird immer aus dem Feld 13 des Headers ermittelt",
)
belegfeld_1: Optional[str] = Field(
alias="Belegfeld 1",
default=None,
pattern=r"^[\w$%\-/]{0,36}$",
description="Rechnungs-/Belegnummer, wird als 'Schlüssel' für den Ausgleich offener Rechnungen genutzt",
)
belegfeld_2: Optional[str] = Field(
alias="Belegfeld 2",
default=None,
pattern=r"^[\w$%\-/]{0,12}$",
description="Mehrere Funktionen",
)
skonto: Optional[str] = Field(
alias="Skonto",
default=None,
pattern=r"^([1-9]\d{0,7}[,]\d{2})$",
description="Skontobetrag, z.B. 3,71 - nur bei Zahlungsbuchungen zulässig",
)
buchungstext: str = Field(
alias="Buchungstext",
max_length=60,
description="0-60 Zeichen",
)
postensperre: Optional[Literal[0, 1]] = Field(
alias="Postensperre",
default=None,
description="Mahn- oder Zahlsperre, 0 = keine Sperre (default), 1 = Sperre",
)

def model_dump_csv_header(self) -> str:
output = StringIO()
datev_writer = csv.DictWriter(
output,
fieldnames=self.model_dump(by_alias=True).keys(),
quoting=csv.QUOTE_NONNUMERIC,
delimiter=";",
)
datev_writer.writeheader()
return output.getvalue().strip()


def convert_personio_to_datev(request) -> HttpResponse:
datev_settings = DatevSettings(
consultant_number=request.POST["consultant-number"],
client_numer=request.POST["client-number"],
)

try:
worksheet = load_workbook(request.FILES["personio-file"]).active
except Exception as e:
raise ValueError("Personio file could not be loaded, please check the format") from e

def get_booking(row) -> tuple[date, DatevBooking]:
(datum, _, _, umsatz, sh, _, _, gegenkonto, konto, belegfeld_1, buchungstext, *_) = row

datum = datetime.strptime(datum, "%d.%m.%Y").date()

soll_haben = "S" if not sh else sh

if umsatz < 0:
soll_haben = "H" if soll_haben == "S" else "S"

return (
datum,
DatevBooking(
umsatz=float_to_german(abs(umsatz)),
soll_haben_kz=soll_haben,
konto=konto,
gegenkonto=gegenkonto,
belegdatum=datum.strftime("%d%m"),
belegfeld_1=belegfeld_1,
buchungstext=buchungstext,
),
)

bookings = [
get_booking([value.strip() if isinstance(value, str) else value for value in row])
for row in worksheet.iter_rows(min_row=3, values_only=True)
if any(row)
]

(datum, first_booking), *_ = bookings

header = DatevHeader(
flag="EXTF",
format_category=21,
format_name="Buchungsstapel",
format_version=9,
consultant_number=datev_settings.consultant_number,
client_numer=datev_settings.client_numer,
business_year_start=date_to_datev(datum.replace(month=1, day=1)),
gl_account_length=4,
date_from=date_to_datev(datum.replace(day=1)),
date_till=date_to_datev(datum.replace(day=calendar.monthrange(datum.year, datum.month)[1])),
designation=f"Lohnbuchungen {datum.strftime('%Y-%m')}",
currency_code="EUR",
locking=0,
gl_chart_of_accounts="03",
)

target_filename = f"EXTF_Personio-{datum.strftime('%Y-%m')}.csv"

contents = "\r\n".join(
[header.model_dump_csv()]
+ [first_booking.model_dump_csv_header()]
+ [booking.model_dump_csv() for _, booking in bookings],
).encode("cp1252")

response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = "attachment; filename=%s" % smart_str(target_filename)
response.write(contents)

return response


def index(request):
if request.method == "GET":
return render(request, "datev.html")
elif request.method == "POST":
try:
response = convert_personio_to_datev(request)
except Exception as e:
message.error(request, e)
return render(request, "datev.html")
else:
return response
else:
return HttpResponseBadRequest
Loading

0 comments on commit 2deb9db

Please sign in to comment.