Merge pull request 'fix(pdfamount): суммы DKV/Diashop и др. (метки и форматы) (#27)' (#28) from fix/pdfamount-amounts#27 into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 4s
Tests / Test (Go 1.25) (push) Successful in 18s
Tests / Test (Go stable) (push) Successful in 19s

This commit was merged in pull request #28.
This commit is contained in:
2026-09-15 08:55:40 +01:00
2 changed files with 216 additions and 14 deletions
+86 -14
View File
@@ -18,7 +18,36 @@ import (
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
) )
var amountRe = regexp.MustCompile(`(?i)(zu zahlender betrag|rechnungsbetrag)\s*[:\s]*([0-9][0-9.]*,[0-9]{2})`) // amountLabels are the payable-amount labels in priority order: the first
// label present in a document wins. Within one label the last amount is taken,
// because totals usually come last.
//
// "gesamtsumme" is not in the original list but is the real label on Diashop
// invoices: "Gesamtsumme (inkl. Steuern)".
var amountLabels = []string{
"zu zahlender betrag",
"rechnungsbetrag",
"rechnungsendbetrag",
"endbetrag",
"zahlbetrag",
"bruttobetrag",
"gesamtbetrag",
"gesamtsumme",
"betrag",
"total",
"summe",
}
// amountRes matches "<label> [optional (comment)] [: -] <number>" for every
// label, in the same priority order as amountLabels.
var amountRes = func() []*regexp.Regexp {
res := make([]*regexp.Regexp, 0, len(amountLabels))
for _, label := range amountLabels {
res = append(res, regexp.MustCompile(
`(?i)\b`+regexp.QuoteMeta(label)+`\b\s*(?:\([^)]*\))?\s*[:\-]?\s*([0-9]+(?:[.,][0-9]+)*)`))
}
return res
}()
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -105,20 +134,63 @@ func pdfAmount(ctx context.Context, c *onlyoffice.Client, id string) (string, er
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return "", err return "", err
} }
m := amountRe.FindStringSubmatch(buf.String()) return extractAmount(buf.String()), nil
if m == nil {
return "", nil
}
return parseDe(m[2]), nil
} }
// parseDe turns "1.234,56" into 1234.56. // extractAmount returns the normalised ("1234.56") payable amount found in
func parseDe(s string) string { // text, or "" if no known label matches.
s = strings.ReplaceAll(s, ".", "") func extractAmount(text string) string {
s = strings.ReplaceAll(s, ",", ".") for _, re := range amountRes {
v, err := strconv.ParseFloat(s, 64) ms := re.FindAllStringSubmatch(text, -1)
if err != nil { if len(ms) == 0 {
return s continue
}
if v, ok := normalizeAmount(ms[len(ms)-1][1]); ok {
return v
}
} }
return strconv.FormatFloat(v, 'f', 2, 64) return ""
}
// normalizeAmount turns "1.234,56" (DE), "1,234.56" (EN) or "1234.56" into
// "1234.56". The rightmost separator is decimal only when followed by one or
// two digits; otherwise every separator is a thousands separator.
func normalizeAmount(s string) (string, bool) {
last := -1
for i := 0; i < len(s); i++ {
if s[i] == '.' || s[i] == ',' {
last = i
}
}
var dec byte
if last >= 0 {
digits := 0
for i := last + 1; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return "", false
}
digits++
}
if digits == 1 || digits == 2 {
dec = s[last]
}
}
var b strings.Builder
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c >= '0' && c <= '9':
b.WriteByte(c)
case (c == '.' || c == ',') && c == dec:
b.WriteByte('.')
case c == '.' || c == ',':
// thousands separator
default:
return "", false
}
}
v, err := strconv.ParseFloat(b.String(), 64)
if err != nil {
return "", false
}
return strconv.FormatFloat(v, 'f', 2, 64), true
} }
+130
View File
@@ -0,0 +1,130 @@
package main
import "testing"
func TestExtractAmount(t *testing.T) {
tests := []struct {
name, text, want string
}{
{
name: "rechnungsbetrag de format",
text: "Rechnungsbetrag: 1.234,56 €",
want: "1234.56",
},
{
name: "rechnungsbetrag en thousands and dot",
text: "Rechnungsbetrag: 1,234.56",
want: "1234.56",
},
{
name: "rechnungsbetrag plain dot",
text: "Rechnungsbetrag: 1234.56",
want: "1234.56",
},
{
name: "rechnungsbetrag de comma only",
text: "Rechnungsbetrag: 1234,56",
want: "1234.56",
},
{
name: "currency suffix eur",
text: "Rechnungsbetrag: 1.234,56 EUR",
want: "1234.56",
},
{
name: "zu zahlender betrag wins over rechnungsbetrag",
text: "Zu zahlender Betrag: 10,00\nRechnungsbetrag: 99,00",
want: "10.00",
},
{
name: "rechnungsbetrag wins over endbetrag",
text: "Endbetrag: 20,00\nRechnungsbetrag: 30,00",
want: "30.00",
},
{
name: "bruttobetrag wins over bare betrag",
text: "Bruttobetrag: 50,00\nBetrag: 10,00",
want: "50.00",
},
{
name: "gesamtbetrag wins over bare betrag",
text: "Gesamtbetrag: 80,00\nBetrag: 10,00",
want: "80.00",
},
{
name: "last occurrence of same label wins",
text: "Rechnungsbetrag: 10,00\nRechnungsbetrag: 20,00",
want: "20.00",
},
{
name: "endbetrag fallback",
text: "Endbetrag: 42,00",
want: "42.00",
},
{
name: "zahlbetrag fallback without colon",
text: "Zahlbetrag 7,50 €",
want: "7.50",
},
{
name: "rechnungsendbetrag beats endbetrag",
text: "Rechnungsendbetrag: 12,00\nEndbetrag: 13,00",
want: "12.00",
},
{
name: "dkv style total line",
text: "Kundenbezogene Daten\n» TOTAL: 123,45 100,00 23,45 123,45\n",
want: "123.45",
},
{
name: "diashop style gesamtsumme with comment",
text: "Zwischensumme\n12,34 €\nZwischensumme\n12,34 €\nVersand & Bearbeitung\n4,95 €\nGesamtsumme (inkl. Steuern)\n17,29 €\n",
want: "17.29",
},
{
name: "diashop picks inclusive total last",
text: "Gesamtsumme (exkl. Steuern)\n12,34 €\nGesamtsumme (inkl. Steuern)\n17,29 €",
want: "17.29",
},
{
name: "no label",
text: "some text without any amount label 12,34",
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := extractAmount(tc.text); got != tc.want {
t.Fatalf("extractAmount()=%q want %q", got, tc.want)
}
})
}
}
func TestNormalizeAmount(t *testing.T) {
tests := []struct {
in string
want string
ok bool
}{
{"1.234,56", "1234.56", true},
{"1,234.56", "1234.56", true},
{"1234.56", "1234.56", true},
{"1234,56", "1234.56", true},
{"1.234.567,89", "1234567.89", true},
{"1,234,567.89", "1234567.89", true},
{"1.234", "1234.00", true},
{"12,5", "12.50", true},
{"12", "12.00", true},
{"", "0.00", false},
}
for _, tc := range tests {
got, ok := normalizeAmount(tc.in)
if ok != tc.ok {
t.Fatalf("normalizeAmount(%q) ok=%v want %v", tc.in, ok, tc.ok)
}
if ok && got != tc.want {
t.Fatalf("normalizeAmount(%q)=%q want %q", tc.in, got, tc.want)
}
}
}