diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6350f0f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test-data/ diff --git a/archived-python/faktura.py b/archived-python/faktura.py new file mode 100755 index 0000000..678bbfe --- /dev/null +++ b/archived-python/faktura.py @@ -0,0 +1,174 @@ +#!/usr/bin/python +import csv +from functools import reduce +import re +import sys + + +if len(sys.argv) < 2: + path2file = "konto.csv" +else: + path2file = sys.argv[1] + +def float2(value, default=0.0): + if value is None: + return default + try: + return float(value) + except ValueError: + return default + +print("Detta program rapporterar skillnader mellan verifikat som refererar till varandra genom beskrivningen av verifikatet. Den utgår från krediter och hittar matchande debeter") + +lines = [] +result = [] +with open(path2file, "r") as file: + lines = file.readlines() + +startLine = 12 + +# READING + +for i, line in enumerate(lines): + if "Konto" in line and "Verifikation" in line: + startLine = i + break + +with open(path2file, "w") as file: + file.writelines(lines[startLine:-1]) + +with open(path2file, "r") as file: + csvreader = csv.DictReader(file, delimiter=";") + for row in csvreader: + result.append(row) +with open(path2file, "w") as file: + file.writelines(lines) + +# FILE RESTORED, PARSE DATA + +krediter = {} +debeter = {} +verfs = {} +sums = {} +header = result.pop(0) +for line in result: + if not line['"Verifikation"']: + continue + verf = line['"Verifikation"'].replace(" ","") + if verf not in verfs.keys(): + verfs[verf] = line + verfs[verf]["Corrections"] = [] + verfs[verf]["Value"] = float2(line['"Debet"'].replace(",",".").replace(" ","")) + verfs[verf]["Value"] -= float2(line['"Kredit"'].replace(",",".").replace(" ","")) + else: + verfs[verf]["Value"] -= float2(line['"Kredit"'].replace(",",".").replace(" ","")) + verfs[verf]["Value"] += float2(line['"Debet"'].replace(",",".").replace(" ","")) + +## ELIMINATE CORRECTIONS + +verfs = dict(reversed(sorted(verfs.items(), key = lambda x: int(x[0][1:])))) + +newVerfs = dict(verfs) + +corrections = [] +for verf, line in verfs.items(): + if "Korrigering" in line['"Beskrivning"']: + corrections.append(verf) +toDelete = [] +missingVerfs = [] +for verf, line in verfs.items(): + if verf in reduce(lambda x,y: x + y, list(map(lambda x: [x[0]] + x[1], toDelete)),[]): + continue + if "Korrigering" in line['"Beskrivning"']: + print(line['"Beskrivning"']) + toRemove = re.findall("(A\\d+)(?:\\D){0,1}",line['"Beskrivning"'])[0] + if toRemove not in verfs.keys(): + missingVerfs.append(toRemove) + else: + newVerfs[toRemove]["Corrections"].append(verf) + newVerfs[toRemove]["Corrections"].extend(newVerfs[verf]["Corrections"]) + newVerfs[toRemove]["Value"] += line["Value"] + ### check if toRemove verf should be removed + if abs(newVerfs[toRemove]["Value"]) < 0.0001: + toDelete.append((toRemove, newVerfs[toRemove]["Corrections"])) + elif len(line["Corrections"]) > 0: + toDelete.append((verf, [None] + line["Corrections"])) + +verfs = newVerfs + +if len(missingVerfs) > 0: + print("") + print(f"VARNING! {missingVerfs} saknas i verifikat listan men refereras till av korrektioner!") + print("") + +if len(toDelete) > 0: + print(f"Tar bort {len(toDelete)} korrigeringspar...") + for v, ks in toDelete: + if ks[0] == None: + print(f" Verifikaten {ks[1:]} delkorrigerar {v}") + ks = ks[1:] + else: + print(f" Verifikaten {ks} korrigerar bort hela {v}") + del verfs[v] + for k in ks: + del verfs[k] + + +## FINISH PARSING DATA +for verf, line in verfs.items(): + if line["Value"] < 0: + krediter[verf] = line + elif line["Value"] > 0: + debeter[verf] = line + +# FIND DIFFS AND LOG INTO DATA STRUCTURE +print("Beräknar skillnader mellan utbetalningar och utlägg") +for kVerf, kLine in krediter.items(): + sums[kVerf] = {"value": kLine["Value"], "debets": [], "date": kLine['"Datum"']} +toDel = [] +for dVerf, dLine in debeter.items(): + debetVal = dLine["Value"] + [company, invoiceNr] = re.findall("( \\w?\\d+)(?:\\D){0,1}",dLine['"Beskrivning"']) + kVerf = None + for v, kLine in krediter.items(): + if invoiceNr in kLine['"Beskrivning"'] and company in kLine['"Beskrivning"']: + kVerf = v + if kVerf == None: + continue + if debetVal == 0: + continue + if kVerf not in sums: + if kVerf not in corrections: + print(f" I debeterande verifikat {dVerf} berörs {kVerf}, men {kVerf} hittas inte. (saknas kostnadsställe?)") + continue + sumVal = sums[kVerf]["value"] + if abs(sumVal + debetVal) < 0.0001: + sums[kVerf]["value"] = 0 + debetVal = 0 + toDel.append(dVerf) + elif sumVal + debetVal < 0: + sums[kVerf]["value"] += debetVal + debetVal = 0 + toDel.append(dVerf) + else: + sums[kVerf]["value"] = 0 + debetVal = debetVal + sumVal + sums[kVerf]["debets"] = sums[kVerf]["debets"] + [dVerf] + if abs(debetVal) > 0.0001: + debeter[dVerf]["Value"] = debetVal +for dVerf in list(set(toDel)): + del debeter[dVerf] + +# PRINT! + +print("---------------------") +print("Nedan är alla utlägg/kredit verifikat och dess tillhörande betalningar.") +for verf, a in sums.items(): + if abs(a["value"]) > 0.0001: + print(" VARNING! Ofullständig matchning i ",verf, ": ", a) + else: + print(f"{verf}: {a}") +print("") +print("Här kommer omatchade debeter") +for debet, debetLine in debeter.items(): + print(debet + ": " + debetLine['"Beskrivning"'] + " -- ", debetLine["Value"]) diff --git a/findUniques.py b/archived-python/findUniques.py similarity index 100% rename from findUniques.py rename to archived-python/findUniques.py diff --git a/findUniques2.py b/archived-python/findUniques2.py similarity index 100% rename from findUniques2.py rename to archived-python/findUniques2.py diff --git a/example.txt b/example.txt deleted file mode 100644 index 82469a1..0000000 --- a/example.txt +++ /dev/null @@ -1,272 +0,0 @@ -A214 -Utlägg för överlämning - Filip Wallin (DAG) --1 155,00 --1 155,00 -A215 -Utlägg för möteskostnader - Tobias Olsson (DAG) --202,30 --1 357,30 -A216 -Utlägg för representationskläder - Viktor Käll (DAG) --7 103,20 --8 460,50 -A218 -Utlägg för representationskläder, 1800kr ligger inom godkänd budget för representationskläder av Styret - Salam Hani (DAG) --1 800,00 --10 260,50 -A219 -Utlägg för representationskläder, 1800kr ligger inom godkänd budget för representationskläder av Styret - Gustav Bruhn (DAG) --1 800,00 --12 060,50 -A217 -Utlägg för representationskläder - Viktor Käll (DAG) --1 749,30 --13 809,80 -A300 -utlägg A216 -7 103,20 --6 706,60 -A302 -utlägg A215 -202,30 --6 504,30 -A304 -utlägg A219 -1 800,00 --4 704,30 -A305 -utlägg A218 -1 800,00 --2 904,30 -A306 -utlägg A214 -1 155,00 --1 749,30 -A307 -utlägg A217 -1 749,30 -0,00 -A3777 -Utlägg för aspning - Viktor Käll (DAG) --1 183,30 --1 183,30 -A1223 -Utlägg för möteskostnader - Gustav Bruhn (DAG) --271,14 --1 454,44 -A1222 -Utlägg för möteskostnader - Viktor Käll (DAG) --265,18 --1 719,62 -A1265 -Betalning av utlägg A1222 (DAG) -265,18 --1 454,44 -A1266 -Betalning av utlägg A1223 -271,14 --1 183,30 -A1440 -Utlägg för möteskostnader - Tobias Olsson (DAG) --263,02 --1 446,32 -A1844 -Utlägg för teambuilding - Alfred Karlsson (DAG) --564,00 --2 010,32 -A1846 -Utlägg för teambuilding - Viktor Käll (DAG) --943,68 --2 954,00 -A1848 -Utlägg för matinköp afterschool - Alfred Karlsson (DAG) --772,89 --3 726,89 -A1847 -Utlägg för matinköp afterschool - Filip Wallin (DAG) --375,40 --4 102,29 -A1845 -Utlägg för möteskostnader - Tim Karlsson (DAG) --236,33 --4 338,62 -A1850 -Betalning av utlägg A1846 (DAG) -943,68 --3 394,94 -A1851 -Betalning av utlägg A1847 (DAG) -375,40 --3 019,54 -A1905 -Betalning av utlägg A1848 (DAG) -772,89 --2 246,65 -A1906 -Betalning av utlägg A1844 (DAG) -564,00 --1 682,65 -A1907 -Betalning av utlägg A1440 (DAG) -263,02 --1 419,63 -A1908 -Betalning av utlägg A1845 (DAG) -236,33 --1 183,30 -A1992 -Utlägg för möteskostnader - Viktor Käll (DAG) --261,92 --1 445,22 -A1993 -Utlägg för afterschool - Salam Hani (DAG) --83,46 --1 528,68 -A1994 -A1993 -83,46 --1 445,22 -A1995 -A1992 -261,92 --1 183,30 -A2525 -Utlägg för möteskostnader - Viktor Käll (DAG) --110,80 --1 294,10 -A2479 -Utlägg för alkoholinköp pub - Salam Hani (DAG) --2 789,20 --4 083,30 -A2526 -Utlägg för möteskostnader - Tim Karlsson (DAG) --213,85 --4 297,15 -A2813 -Utlägg för teambuilding - Viktor Käll (DAG) --1 750,00 --6 047,15 -A2527 -Utlägg för möteskostnader - Alfred Karlsson (DAG) --257,09 --6 304,24 -A2833 -Betalning av utlägg A2479 (Kommitté) -2 789,20 --3 515,04 -A3774 -Utlägg för möteskostnader - Tobias Olsson (DAG) --196,44 --3 711,48 -A3305 -Utlägg för möteskostnader - Salam Hani (DAG) --280,00 --3 991,48 -A2830 -Betalning av utlägg A2527 (DAG) -257,09 --3 734,39 -A2831 -Betalning av utlägg A2526 (Kommitté) -213,85 --3 520,54 -A2832 -Betalning av utlägg A2525 (Kommitté) -110,80 --3 409,74 -A2829 -Betalning av utlägg A2813 (DAG) -1 750,00 --1 659,74 -A3306 -Utlägg för möteskostnader - Viktor Käll (DAG) --280,00 --1 939,74 -A3307 -Utlägg för aspning - Alfred Karlsson (DAG) --1 063,78 --3 003,52 -A3308 -Utlägg för aspning - Filip Wallin (DAG) --939,50 --3 943,02 -A3309 -Utlägg för möteskostnader - Tobias Olsson (DAG) --280,00 --4 223,02 -A3310 -Utlägg för möteskostnader - Viktor Käll (DAG) --239,14 --4 462,16 -A3749 -Utlägg för [matinköp t.ex. afterschool] - Filip Wallin (DAG) --3 817,17 --8 279,33 -A3746 -Utlägg för [arrangemangskostnader] - Filip Wallin (DAG) --4 370,00 --12 649,33 -A3313 -Betalning av utlägg A3308 (DAG) -939,50 --11 709,83 -A3314 -Betalning av utlägg A3306 (DAG) -280,00 --11 429,83 -A3315 -Betalning av utlägg A3310 (DAG) -239,19 --11 190,64 -A3316 -Betalning av utlägg A3305 (DAG) -280,00 --10 910,64 -A3317 -Betalning av utlägg A3309 (DAG) -280,00 --10 630,64 -A3318 -Betalning av utlägg A3307 (DAG) -1 063,78 --9 566,86 -A3751 -Utlägg för möteskostnader - Viktor Käll (DAG) --202,95 --9 769,81 -A3842 -Utlägg för [arrangemangskostnader] - Alfred Karlsson (DAG) --3 693,40 --13 463,21 -A3745 -Utlägg för möteskostnader - Salam Hani (DAG) --222,25 --13 685,46 -A3796 -Betalning av utlägg A3751 (DAG) -202,95 --13 482,51 -A3797 -Betalning av utlägg A3777 (DAG) -1 183,30 --12 299,21 -A3798 -Betalning av utlägg A3749 (DAG) -3 817,17 --8 482,04 -A3799 -Betalning av utlägg A3745 (DAG) -222,25 --8 259,79 -A3800 -Betalning av utlägg A3746 (DAG) -4 370,00 --3 889,79 -A3801 -Betalning av utlägg A3774 (DAG) -196,44 --3 693,35 -A3843 -Betalning av utlägg A3842 (DAG) -3 693,40 -0,05 diff --git a/go-rewrite/bookkeep b/go-rewrite/bookkeep new file mode 100755 index 0000000..903c68b Binary files /dev/null and b/go-rewrite/bookkeep differ diff --git a/go-rewrite/bookkeep.zsh b/go-rewrite/bookkeep.zsh new file mode 100644 index 0000000..c529e70 --- /dev/null +++ b/go-rewrite/bookkeep.zsh @@ -0,0 +1,212 @@ +#compdef bookkeep +compdef _bookkeep bookkeep + +# zsh completion for bookkeep -*- shell-script -*- + +__bookkeep_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_bookkeep() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __bookkeep_debug "\n========= starting completion logic ==========" + __bookkeep_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __bookkeep_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __bookkeep_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., bookkeep -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __bookkeep_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __bookkeep_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __bookkeep_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __bookkeep_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __bookkeep_debug "No directive found. Setting do default" + directive=0 + fi + + __bookkeep_debug "directive: ${directive}" + __bookkeep_debug "completions: ${out}" + __bookkeep_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __bookkeep_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __bookkeep_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __bookkeep_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __bookkeep_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __bookkeep_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __bookkeep_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __bookkeep_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __bookkeep_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __bookkeep_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __bookkeep_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __bookkeep_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __bookkeep_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __bookkeep_debug "_describe did not find completions." + __bookkeep_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __bookkeep_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __bookkeep_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_bookkeep" ]; then + _bookkeep +fi diff --git a/go-rewrite/cmd/outlays.go b/go-rewrite/cmd/outlays.go new file mode 100644 index 0000000..e00d67b --- /dev/null +++ b/go-rewrite/cmd/outlays.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "log/slog" + "math" + "slices" + + "github.com/fatih/color" + "github.com/rodaine/table" + "github.com/spf13/cobra" + "kryddan.xyz/bookkeep/process" + "kryddan.xyz/bookkeep/vismaparse" +) + +func printFaults(faults []process.FaultyOutlay) { + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Verification", "Description", "Related Verf.", "Error"). + WithPadding(2). + WithHeaderFormatter(headerFmt). + WithFirstColumnFormatter(columnFmt) + + totalChange := 0 + for _, fault := range faults { + localChange := fault.Row.Debet - fault.Row.Kredit + totalChange += localChange + localDecimal := int(math.Max(float64(localChange%100), float64(-localChange%100))) + difference := fmt.Sprintf("%6d.%02d", localChange/100, localDecimal) + description := fault.Row.Description + if len(description) > 80 { + description = description[:76] + "..." + } + if len(fault.ContributingVerifications) == 0 { + tbl.AddRow(fault.Row.Verification, description, "", difference) + } else { + tbl.AddRow(fault.Row.Verification, description, fault.ContributingVerifications, difference) + } + } + totalDecimal := int(math.Max(float64(totalChange%100), float64(-totalChange%100))) + totalChangeString := fmt.Sprintf("%6d.%02d", totalChange/100, totalDecimal) + tbl.AddRow("Total", "", "", totalChangeString) + + tbl.Print() +} + +func OutlayCmd() *cobra.Command { + unitsFlagName := "unit" + accountNumberFlagName := "account" + cmd := &cobra.Command{ + Use: "outlays [file]", + Short: "This command analyses outlays in the provided file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filePath := args[0] + units, err := cmd.Flags().GetStringArray(unitsFlagName) + if err != nil { + slog.Error("get --units flag", slog.Any("err", err)) + } + accountNumber, err := cmd.Flags().GetInt(accountNumberFlagName) + if err != nil { + slog.Error("get --units flag", slog.Any("err", err)) + } + + org, err := vismaparse.LoadSheet(filePath) + if err != nil { + slog.Error("load sheet failed", slog.Any("err", err)) + return err + } + + for _, account := range org.Accounts { + if account.AccountNumber == accountNumber { + change := 0 + for unit, perUnitAccount := range process.PerOrgUnit(account) { + if len(units) != 0 && !slices.Contains(units, unit) { + continue + } + faults, ok := process.ValidateOutlays(perUnitAccount) + if ok { + continue + } + fmt.Printf("\n----- Faulty verifications in %s -----\n", unit) + for _, fault := range faults { + change += fault.Row.Debet + change -= fault.Row.Kredit + } + printFaults(faults) + } + } + } + return nil + }, + } + + cmd.Flags().StringArray(unitsFlagName, []string{""}, "The organizational units to analyse. Omit to include all") + cmd.Flags().Int(accountNumberFlagName, 2820, "The account number which tracks outlays") + return cmd +} diff --git a/go-rewrite/cmd/root.go b/go-rewrite/cmd/root.go new file mode 100644 index 0000000..898f401 --- /dev/null +++ b/go-rewrite/cmd/root.go @@ -0,0 +1,42 @@ +/* +Copyright © 2026 NAME HERE +*/ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "bookkeep", + Short: "A book keeping helper to validate certain accounts", + Long: `bookkeep is a CLI library that validates book keeping accounts. +to quickly create a Cobra application.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + rootCmd.AddCommand() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + + // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.vismascripts.yaml)") + + // Cobra also supports local flags, which will only run + // when this action is called directly. + rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + outlays := OutlayCmd() + rootCmd.AddCommand(outlays) +} diff --git a/go-rewrite/go.mod b/go-rewrite/go.mod new file mode 100644 index 0000000..99838fd --- /dev/null +++ b/go-rewrite/go.mod @@ -0,0 +1,16 @@ +module kryddan.xyz/bookkeep + +go 1.27.1 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rodaine/table v1.4.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go-rewrite/go.sum b/go-rewrite/go.sum new file mode 100644 index 0000000..3fb449e --- /dev/null +++ b/go-rewrite/go.sum @@ -0,0 +1,24 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rodaine/table v1.4.0 h1:sbGLYXgrc15Db32nuFRamNXP3QdfTxTxXIvdM3fsjEA= +github.com/rodaine/table v1.4.0/go.mod h1:2XS7TM7slleLUM1km5Zsr9Ks+F2p7Y8F5ycDQq2Z7hk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go-rewrite/install-zsh.sh b/go-rewrite/install-zsh.sh new file mode 100755 index 0000000..c3c62cc --- /dev/null +++ b/go-rewrite/install-zsh.sh @@ -0,0 +1,11 @@ +#!/bin/bash +./bookkeep completion zsh > _bookkeep +sudo mkdir -p /usr/local/share/zsh/site-functions +sudo cp _bookkeep /usr/local/share/zsh/site-functions +mkdir -p ~/.zsh/completions +cp _bookkeep ~/.zsh/completions + +echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc +echo 'autoload -U compinit && compinit' >> ~/.zshrc + +rm _bookkeep diff --git a/go-rewrite/main.go b/go-rewrite/main.go new file mode 100644 index 0000000..9bea400 --- /dev/null +++ b/go-rewrite/main.go @@ -0,0 +1,10 @@ +/* +Copyright © 2026 NAME HERE +*/ +package main + +import "kryddan.xyz/bookkeep/cmd" + +func main() { + cmd.Execute() +} diff --git a/go-rewrite/process/process.go b/go-rewrite/process/process.go new file mode 100644 index 0000000..39eff20 --- /dev/null +++ b/go-rewrite/process/process.go @@ -0,0 +1,116 @@ +package process + +import ( + "cmp" + "slices" + "strings" + + "kryddan.xyz/bookkeep/types" +) + +func PerOrgUnit(account types.BookkeepingAccount) map[string]types.BookkeepingAccount { + out := map[string]types.BookkeepingAccount{} + for _, row := range account.Rows { + unit := strings.ToLower(*cmp.Or(row.OrganizationUnit, new(""))) + if _, ok := out[unit]; !ok { + out[unit] = types.BookkeepingAccount{ + Name: account.Name, + AccountNumber: account.AccountNumber, + Rows: []types.BookkeepingAccountRow{}, + } + } + v := out[unit] + v.Rows = append(v.Rows, row) + out[unit] = v + } + + return out +} + +type FaultyOutlay struct { + Row types.BookkeepingAccountRow + ContributingVerifications []types.Verification +} + +func ValidateOutlays(account types.BookkeepingAccount) ([]FaultyOutlay, bool) { + rows := sortRows(account.Rows) + slices.Reverse(rows) + + talliedVerifications := map[string]types.BookkeepingAccountRow{} + originalVerifications := map[string]types.BookkeepingAccountRow{} + for _, row := range rows { + if r, ok := talliedVerifications[row.Verification.String()]; ok { + r.Debet += row.Debet + r.Kredit += row.Kredit + talliedVerifications[row.Verification.String()] = r + originalVerifications[row.Verification.String()] = r + continue + } + talliedVerifications[row.Verification.String()] = row + originalVerifications[row.Verification.String()] = row + } + + backlinks := map[string][]string{} + for rowVerf, row := range originalVerifications { + if len(row.LinkedVerifications) == 0 { + continue + } + if row.Kredit == 0 && row.Debet == 0 { + continue + } + alteredRow := talliedVerifications[rowVerf] + if alteredRow.Kredit == 0 && alteredRow.Debet == 0 { + continue + } + + alterValue := func(old int, adjustment int) (int, int) { + v := old + adjustment + if v > 0 { + return v, 0 + } else { + return 0, -v + } + } + + for _, linked := range row.LinkedVerifications { + linkedRow := talliedVerifications[linked.String()] + if linkedRow.Debet != 0 && row.Kredit != 0 { + linkedRow.Debet, row.Kredit = alterValue(linkedRow.Debet, -row.Kredit) + } else if linkedRow.Kredit != 0 && row.Debet != 0 { + linkedRow.Kredit, row.Debet = alterValue(linkedRow.Kredit, -row.Debet) + } + backlinks[linked.String()] = append(backlinks[linked.String()], row.Verification.String()) + talliedVerifications[linked.String()] = linkedRow + } + + talliedVerifications[row.Verification.String()] = row + } + + faults := []FaultyOutlay{} + for verf, row := range talliedVerifications { + if row.Debet != 0 || row.Kredit != 0 { + contributingVerfs := []types.Verification{} + bls := backlinks[verf] + for _, v := range bls { + contributingVerfs = append(contributingVerfs, talliedVerifications[v].Verification) + } + faults = append(faults, FaultyOutlay{ + Row: row, + ContributingVerifications: contributingVerfs, + }) + } + } + + return faults, len(faults) == 0 +} + +func sortRows(rows []types.BookkeepingAccountRow) []types.BookkeepingAccountRow { + slices.SortFunc(rows, func(a, b types.BookkeepingAccountRow) int { + if a.Verification.Series != b.Verification.Series { + return 0 + } + return a.Verification.Number - b.Verification.Number + }) + + return rows +} diff --git a/go-rewrite/types/types.go b/go-rewrite/types/types.go new file mode 100644 index 0000000..1c8a9a0 --- /dev/null +++ b/go-rewrite/types/types.go @@ -0,0 +1,41 @@ +package types + +import ( + "fmt" + "time" +) + +type Verification struct { + Series string + Number int +} + +type Invoice struct { + Number int +} + +type BookkeepingAccountRow struct { + Verification Verification + Date time.Time + Description string + Project string + OrganizationUnit *string + LinkedVerifications []Verification + LinkedInvoices []Invoice + Debet int + Kredit int +} + +type BookkeepingAccount struct { + Name string + AccountNumber int + Rows []BookkeepingAccountRow +} + +type Organization struct { + Accounts []BookkeepingAccount +} + +func (v Verification) String() string { + return fmt.Sprintf("%s%d", v.Series, v.Number) +} diff --git a/go-rewrite/vismaparse/vismascripts.go b/go-rewrite/vismaparse/vismascripts.go new file mode 100644 index 0000000..76359c7 --- /dev/null +++ b/go-rewrite/vismaparse/vismascripts.go @@ -0,0 +1,261 @@ +package vismaparse + +import ( + "encoding/csv" + "errors" + "fmt" + "log/slog" + "os" + "regexp" + "strconv" + "strings" + "time" + + "kryddan.xyz/bookkeep/types" +) + +var ( + ErrInvalidRow error = errors.New("invalid input row") + ErrInvalidTable error = errors.New("invalid input table") +) + +type accountRange struct { + High int + Low int +} + +func LoadSheet(path string) (types.Organization, error) { + f, err := os.Open(path) + if err != nil { + return types.Organization{}, err + } + defer func() { + // Close the spreadsheet. + if err := f.Close(); err != nil { + fmt.Println(err) + } + }() + + csvReader := csv.NewReader(f) + csvReader.Comma = ';' + csvReader.LazyQuotes = true + + output, err := csvReader.ReadAll() + if err != nil { + return types.Organization{}, err + } + + trimmed := trimHeaders(output) + + ranges := accountRanges(trimmed) + accounts := []types.BookkeepingAccount{} + for _, r := range ranges { + account, err := constructBookkeepingAccount(trimmed[r.Low:r.High]) + if err != nil { + slog.Error("construct bookeeping account", slog.Any("err", err)) + continue + } + accounts = append(accounts, account) + } + + return types.Organization{Accounts: accounts}, nil +} + +func trimHeaders(input [][]string) [][]string { + for i, line := range input { + if strings.Contains(line[0], "Konto") { + return input[i+1:] + } + + } + return input +} + +func accountRanges(input [][]string) []accountRange { + var low int + ranges := []accountRange{} + for i, line := range input { + if line[0] != "" && low == 0 { + low = i + } else if line[0] != "" { + ranges = append( + ranges, + accountRange{ + High: i - 1, + Low: low, + }, + ) + low = i + } + } + + return ranges +} + +func constructBookkeepingAccount(input [][]string) (types.BookkeepingAccount, error) { + if len(input) < 2 || input[0][0] == "" { + return types.BookkeepingAccount{}, ErrInvalidTable + } + splitAccountString := strings.SplitN(input[0][0], " ", 2) + accountNumber, err := strconv.Atoi(splitAccountString[0]) + if err != nil { + return types.BookkeepingAccount{}, err + } + + rows := make([]types.BookkeepingAccountRow, 0, len(input)) + for _, line := range input[2:] { + row, err := cosntructBookeepingAccountRow(line) + if err != nil { + if errors.Is(err, ErrInvalidRow) { + continue + } + return types.BookkeepingAccount{}, err + } + rows = append(rows, row) + } + + return types.BookkeepingAccount{ + AccountNumber: accountNumber, + Name: splitAccountString[1], + Rows: rows, + }, nil +} + +const ( + ACCOUNT_INDEX int32 = 0 + VERIFICATION_INDEX int32 = 1 + DATE_INDEX int32 = 2 + DESCRIPTION_INDEX int32 = 3 + PROJECT_INDEX int32 = 7 + KOSTNADSSTALLE_INDEX int32 = 8 + DEBET_INDEX int32 = 10 + KREDIT_INDEX int32 = 11 +) + +// index : value +// 0 : Account +// 1 : Verification +// 2 : Date +// 3 : Description +// 4 : Transaction text +// 5-6: VAT +// 7 : Project +// 8 : Kostnadsställe +// 9 : Motpart +// 10: Debet +// 11: Kredit +// 12: Beloppp +// 13: Saldo +func cosntructBookeepingAccountRow(input []string) (types.BookkeepingAccountRow, error) { + if len(input) <= 11 || input[VERIFICATION_INDEX] == "" { + return types.BookkeepingAccountRow{}, ErrInvalidRow + } + verification, err := parseVerification(input[VERIFICATION_INDEX]) + if err != nil { + return types.BookkeepingAccountRow{}, err + } + + debet, err := parseAmount(input[DEBET_INDEX]) + if err != nil { + return types.BookkeepingAccountRow{}, err + } + kredit, err := parseAmount(input[KREDIT_INDEX]) + if err != nil { + return types.BookkeepingAccountRow{}, err + } + + date, err := time.Parse(time.DateOnly, input[DATE_INDEX]) + if err != nil { + return types.BookkeepingAccountRow{}, err + } + + linkedVerifications, err := parseLinkedVerifications(input[DESCRIPTION_INDEX]) + if err != nil { + return types.BookkeepingAccountRow{}, err + } + + return types.BookkeepingAccountRow{ + Verification: verification, + LinkedVerifications: linkedVerifications, + Project: input[PROJECT_INDEX], + Description: input[DESCRIPTION_INDEX], + OrganizationUnit: &input[KOSTNADSSTALLE_INDEX], + Debet: debet, + Kredit: kredit, + Date: date, + }, nil +} + +func parseVerification(input string) (types.Verification, error) { + r := regexp.MustCompile(`(?P