Files
go-onlyoffice/cmd/kontolink/main.go
T
eSlider af8e3b053a
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 1m22s
Tests / Test (Go stable) (pull_request) Successful in 1m21s
feat(oo): kontoblatt bulk tools, oo dav, update, retry (#22)
2026-09-14 21:03:39 +00:00

479 lines
11 KiB
Go

// Command kontolink fills the "Link" column of a Kontoblatt ("ungeklärte
// Posten") XLSX by matching each row to an OnlyOffice document.
//
// Strategy (deterministic, conservative — no LLM):
// 1. Beleg token (letters/digits from the "Beleg" column) appears in the file
// title; among candidates prefer (a) the row's month, (b) real invoices over
// copies/dupes, and require the result to be unique;
// 2. else supplier + row month + "rechnung", again unique.
//
// A file is linked at most once (rows already carrying a link are kept and their
// file counts as used). Ambiguous rows are left UNLINKED for manual review.
//
// Usage: kontolink <IN_XLSX> <INDEX_TSV> <OUT_XLSX>
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/xuri/excelize/v2"
)
var (
dateRe = regexp.MustCompile(`^\d{2}\.\d{2}\.\d{4}$`)
nonAln = regexp.MustCompile(`[^0-9a-z]+`)
fileID = regexp.MustCompile(`fileid=(\d+)`)
)
func parseDay(s string) (time.Time, bool) {
t, err := time.Parse("02.01.2006", strings.TrimSpace(s))
return t, err == nil
}
func titleDay(title string) (time.Time, bool) {
if len(title) >= 10 {
if t, err := time.Parse("2006-01-02", title[:10]); err == nil {
return t, true
}
}
return time.Time{}, false
}
// nearest picks the candidate whose title date is closest to rd. Ties and
// undated candidates (when >1) are rejected.
func nearest(cands []entry, rd time.Time) (entry, bool) {
if len(cands) == 1 {
return cands[0], true
}
best, bestD, tie := -1, 0.0, false
for i, e := range cands {
td, ok := titleDay(e.title)
if !ok {
continue
}
d := td.Sub(rd).Hours() / 24
if d < 0 {
d = -d
}
if best < 0 || d < bestD {
best, bestD, tie = i, d, false
} else if d == bestD {
tie = true
}
}
if best < 0 || tie {
return entry{}, false
}
return cands[best], true
}
const linkPrefix = "https://office.pro-dukt.de/Products/Files/DocEditor.aspx?fileid="
type entry struct {
id, path, title, norm string
}
func norm(s string) string { return nonAln.ReplaceAllString(strings.ToLower(s), "") }
func main() {
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "usage: kontolink <IN_XLSX> <INDEX_TSV> <OUT_XLSX>")
os.Exit(2)
}
in, idxPath, out := os.Args[1], os.Args[2], os.Args[3]
idxRaw, err := os.ReadFile(idxPath)
if err != nil {
panic(err)
}
var entries []entry
for _, line := range strings.Split(string(idxRaw), "\n") {
parts := strings.Split(line, "\t")
if len(parts) < 4 || parts[0] == "" {
continue
}
entries = append(entries, entry{id: parts[0], path: parts[2], title: parts[3], norm: norm(parts[3])})
}
f, err := excelize.OpenFile(in)
if err != nil {
panic(err)
}
defer f.Close()
sheet := f.GetSheetList()[0]
rows, err := f.GetRows(sheet)
if err != nil {
panic(err)
}
// optional 5th arg: amounts TSV "file_id\ttitle\tamount" (see cmd/pdfamount)
var amts []amtEntry
if len(os.Args) >= 6 && os.Args[5] != "" {
amts = loadAmounts(os.Args[5])
}
used := map[string]bool{}
for _, r := range rows {
if m := fileID.FindStringSubmatch(cell(r, 7)); m != nil {
used[m[1]] = true
}
}
var linked, byBeleg, bySupplier, byAmount, unmatched, ambiguous int
for i, r := range rows {
if i == 0 || !dateRe.MatchString(cell(r, 0)) || strings.TrimSpace(cell(r, 7)) != "" {
continue
}
beleg := norm(cell(r, 3))
supplier := supplierNorm(cell(r, 2))
month := monthYear(cell(r, 0))
rd, _ := parseDay(cell(r, 0))
e, kind, ok := pick(entries, used, beleg, supplier, month, rd)
if !ok {
if ae, aok := amountPick(amts, used, supplier, rowAmount(r), rd); aok {
e, kind, ok = entry{id: ae.id, title: ae.title}, "amount", true
}
}
if !ok {
if beleg != "" {
ambiguous++
} else {
unmatched++
}
continue
}
ref, _ := excelize.CoordinatesToCellName(8, i+1)
if err := f.SetCellValue(sheet, ref, linkPrefix+e.id); err != nil {
panic(err)
}
used[e.id] = true
linked++
switch kind {
case "beleg":
byBeleg++
case "supplier":
bySupplier++
case "amount":
byAmount++
}
fmt.Printf("row %3d %-30s -> %s [%s]\n", i+1, cell(r, 2), e.title, kind)
}
if err := f.SaveAs(out); err != nil {
panic(err)
}
fmt.Printf("\nlinked=%d (beleg=%d, supplier=%d, amount=%d), ambiguous=%d, no-candidate=%d\n",
linked, byBeleg, bySupplier, byAmount, ambiguous, unmatched)
// Optional 4th arg: source OnlyOffice file id. Try to update it in place;
// if it is locked (OnlyOffice 500), upload a "(links)" copy next to it.
if len(os.Args) >= 5 && os.Args[4] != "" {
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var src *onlyoffice.FileEntry
if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
var err error
src, err = c.GetFile(ctx, os.Args[4])
return err
}); derr != nil {
panic(derr)
}
folder, title := "", ""
if src.FolderID != nil {
folder = src.FolderID.String()
}
if src.Title != nil {
title = *src.Title
}
uderr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
_, err := c.UpdateFile(ctx, os.Args[4], out)
return err
})
if uderr == nil {
fmt.Printf("updated file %s in place\n", os.Args[4])
return
}
fmt.Printf("in-place update failed (locked?); uploading a copy to folder %s\n", folder)
ext := filepath.Ext(title)
name := strings.TrimSuffix(title, ext) + " (links)" + ext
tmp := filepath.Join(os.TempDir(), name)
data, _ := os.ReadFile(out)
if err := os.WriteFile(tmp, data, 0o600); err != nil {
panic(err)
}
if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
_, _, err := c.UploadToFolderReplacing(ctx, folder, tmp)
return err
}); derr != nil {
panic(derr)
}
fmt.Printf("uploaded copy: %s -> folder %s\n", name, folder)
}
}
func cell(r []string, i int) string {
if i < len(r) {
return strings.TrimSpace(r[i])
}
return ""
}
func supplierNorm(s string) string {
s = strings.ToUpper(s)
if i := strings.Index(s, ","); i >= 0 {
s = s[:i]
}
for _, w := range []string{"RE FEHLT", "GS FEHLT", "WOFR", "WOFÜR"} {
s = strings.ReplaceAll(s, w, "")
}
return norm(s)
}
func monthYear(date string) string {
if len(date) == 10 {
return date[6:10] + "-" + date[3:5]
}
return ""
}
// pick returns an unused candidate. Beleg match wins; supplier+month is a
// fallback. When several candidates qualify, the one closest in time to the row
// date wins; a tie is rejected (ambiguous) rather than guessed.
func pick(entries []entry, used map[string]bool, beleg, supplier, month string, rd time.Time) (entry, string, bool) {
free := func(e entry) bool { return !used[e.id] }
if len(beleg) >= 5 {
var inMonth []entry
for _, e := range entries {
if free(e) && belegMatches(e.norm, beleg) &&
(month == "" || strings.Contains(e.title, month)) {
inMonth = append(inMonth, e)
}
}
if supplier != "" {
var s []entry
for _, e := range inMonth {
if strings.Contains(e.norm, supplier) {
s = append(s, e)
}
}
if len(s) > 0 {
inMonth = s
}
}
inMonth = topRank(inMonth)
if e, ok := nearest(inMonth, rd); ok {
return e, "beleg", true
}
// A Beleg is present but no file carries it: do NOT fall back to a
// supplier guess (that links the wrong invoice).
return entry{}, "", false
}
if supplier != "" && month != "" {
var c []entry
for _, e := range entries {
if free(e) && strings.Contains(e.norm, supplier) &&
strings.Contains(e.title, month) && strings.Contains(e.norm, "rechnung") {
c = append(c, e)
}
}
c = topRank(c)
if e, ok := nearest(c, rd); ok {
return e, "supplier", true
}
}
return entry{}, "", false
}
// topRank keeps only the highest-ranked candidates (real invoice over copy /
// dupe / op), so a tie with a duplicate does not mask the real file.
func topRank(cands []entry) []entry {
if len(cands) < 2 {
return cands
}
best := 0
for _, e := range cands {
if rank(e) > best {
best = rank(e)
}
}
out := cands[:0]
for _, e := range cands {
if rank(e) == best {
out = append(out, e)
}
}
return out
}
func rank(e entry) int {
s := 0
if strings.Contains(e.path, "/2025") || strings.Contains(e.path, "/2024") {
s += 4
}
if strings.Contains(e.norm, "rechnung") {
s += 2
}
if strings.Contains(e.norm, "dupe") || strings.Contains(e.norm, "copy") ||
strings.Contains(e.norm, "op") {
s--
}
return s
}
// belegMatches reports whether a Beleg identifies the file: the whole normalized
// Beleg appears, or (for long numeric Belege, e.g. "24/641393110") an 8-digit
// window of its longest digit run appears.
func belegMatches(titleNorm, beleg string) bool {
if strings.Contains(titleNorm, beleg) {
return true
}
run := longestDigitRun(beleg)
for i := 0; i+8 <= len(run); i++ {
if strings.Contains(titleNorm, run[i:i+8]) {
return true
}
}
return false
}
func longestDigitRun(s string) string {
var best, cur strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
cur.WriteRune(r)
if cur.Len() > best.Len() {
best.Reset()
best.WriteString(cur.String())
}
} else {
cur.Reset()
}
}
return best.String()
}
type amtEntry struct {
id string
title string
norm string
amount float64
date time.Time
hasDate bool
}
func loadAmounts(path string) []amtEntry {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
var out []amtEntry
for _, line := range strings.Split(string(raw), "\n") {
p := strings.Split(line, "\t")
if len(p) < 3 {
continue
}
v, err := strconv.ParseFloat(strings.TrimSpace(p[2]), 64)
if err != nil {
continue
}
e := amtEntry{id: p[0], title: p[1], norm: norm(p[1]), amount: v}
if len(p[1]) >= 10 {
if t, err := time.Parse("2006-01-02", p[1][:10]); err == nil {
e.date, e.hasDate = t, true
}
}
out = append(out, e)
}
return out
}
func rowAmount(r []string) float64 {
if v := parseAmount(cell(r, 4)); v != 0 {
return v
}
return parseAmount(cell(r, 5))
}
func parseAmount(s string) float64 {
s = strings.ReplaceAll(s, "€", "")
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, ",", ".")
if s == "" {
return 0
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}
// amountPick matches a row to an O2 invoice by amount + nearest date. Scoped to
// Telefonica/O2 rows and O2 files, so it cannot cross-link other suppliers.
func amountPick(amts []amtEntry, used map[string]bool, supplier string, amt float64, rd time.Time) (amtEntry, bool) {
if amt <= 0 || len(amts) == 0 {
return amtEntry{}, false
}
if !strings.Contains(supplier, "telefonica") && !strings.Contains(supplier, "o2") {
return amtEntry{}, false
}
var cands []amtEntry
for _, a := range amts {
if used[a.id] || !strings.Contains(a.norm, "o2") {
continue
}
d := a.amount - amt
if d < 0 {
d = -d
}
if d > 0.005 {
continue
}
if a.hasDate && !rd.IsZero() {
days := a.date.Sub(rd).Hours() / 24
if days < 0 {
days = -days
}
if days > 75 {
continue
}
}
cands = append(cands, a)
}
if len(cands) == 1 {
return cands[0], true
}
best, bestD, tie := -1, 0.0, false
for i, a := range cands {
if !a.hasDate {
continue
}
d := a.date.Sub(rd).Hours() / 24
if d < 0 {
d = -d
}
if best < 0 || d < bestD {
best, bestD, tie = i, d, false
} else if d == bestD {
tie = true
}
}
if best < 0 || tie {
return amtEntry{}, false
}
return cands[best], true
}