feat(search): Elasticsearch-клиент (имя + контент) + oo search (#37) #41
+86
-14
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user