230 lines
5.5 KiB
Go
230 lines
5.5 KiB
Go
package invoice
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func ParseMarkdown(r io.Reader) (Document, error) {
|
|
var doc Document
|
|
var section string
|
|
var inAddress bool
|
|
var tableHeader []string
|
|
scanner := bufio.NewScanner(r)
|
|
lineNo := 0
|
|
for scanner.Scan() {
|
|
lineNo++
|
|
line := strings.TrimRight(scanner.Text(), "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "## ") {
|
|
section = strings.TrimSpace(strings.TrimPrefix(trimmed, "## "))
|
|
inAddress = false
|
|
if strings.EqualFold(section, "Prestations") {
|
|
tableHeader = nil
|
|
}
|
|
continue
|
|
}
|
|
if trimmed == "" {
|
|
inAddress = false
|
|
continue
|
|
}
|
|
|
|
switch strings.ToLower(section) {
|
|
case "seller":
|
|
if ok := parsePartyLine(&doc.Seller, line, &inAddress); ok {
|
|
continue
|
|
}
|
|
case "buyer":
|
|
if ok := parsePartyLine(&doc.Buyer, line, &inAddress); ok {
|
|
continue
|
|
}
|
|
case "invoice":
|
|
key, value, ok := splitKeyValue(line)
|
|
if !ok {
|
|
return doc, fmt.Errorf("line %d: expected key/value in Invoice section", lineNo)
|
|
}
|
|
switch normalizeKey(key) {
|
|
case "number":
|
|
doc.Invoice.Number = value
|
|
case "subject":
|
|
doc.Invoice.Subject = value
|
|
case "location":
|
|
doc.Invoice.Location = value
|
|
case "date":
|
|
parsed, err := time.Parse("2006-01-02", value)
|
|
if err != nil {
|
|
return doc, fmt.Errorf("line %d: invalid date %q", lineNo, value)
|
|
}
|
|
doc.Invoice.Date = parsed
|
|
case "description":
|
|
doc.Invoice.Description = value
|
|
default:
|
|
return doc, fmt.Errorf("line %d: unknown invoice field %q", lineNo, key)
|
|
}
|
|
case "items":
|
|
if !strings.Contains(trimmed, "|") {
|
|
return doc, fmt.Errorf("line %d: expected table row in Items section", lineNo)
|
|
}
|
|
cells := splitTableRow(line)
|
|
if len(cells) == 0 {
|
|
continue
|
|
}
|
|
if tableHeader == nil {
|
|
tableHeader = cells
|
|
continue
|
|
}
|
|
if isSeparatorRow(cells) {
|
|
continue
|
|
}
|
|
item, err := parseItemRow(tableHeader, cells)
|
|
if err != nil {
|
|
return doc, fmt.Errorf("line %d: %w", lineNo, err)
|
|
}
|
|
doc.Items = append(doc.Items, item)
|
|
case "payment":
|
|
key, value, ok := splitKeyValue(line)
|
|
if !ok {
|
|
return doc, fmt.Errorf("line %d: expected key/value in Payment section", lineNo)
|
|
}
|
|
switch normalizeKey(key) {
|
|
case "holder":
|
|
doc.Payment.Holder = value
|
|
case "iban":
|
|
doc.Payment.IBAN = value
|
|
default:
|
|
return doc, fmt.Errorf("line %d: unknown payment field %q", lineNo, key)
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return doc, err
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
func parsePartyLine(party *Party, line string, inAddress *bool) bool {
|
|
if *inAddress && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) {
|
|
party.Address = append(party.Address, strings.TrimSpace(line))
|
|
return true
|
|
}
|
|
key, value, ok := splitKeyValue(line)
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch normalizeKey(key) {
|
|
case "name":
|
|
party.Name = value
|
|
*inAddress = false
|
|
return true
|
|
case "address":
|
|
*inAddress = true
|
|
if value != "" {
|
|
party.Address = append(party.Address, value)
|
|
}
|
|
return true
|
|
case "siret":
|
|
party.SIRET = value
|
|
*inAddress = false
|
|
return true
|
|
case "email":
|
|
party.Email = value
|
|
*inAddress = false
|
|
return true
|
|
case "phone":
|
|
party.Phone = value
|
|
*inAddress = false
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func splitKeyValue(line string) (string, string, bool) {
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", false
|
|
}
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
return key, value, true
|
|
}
|
|
|
|
func normalizeKey(key string) string {
|
|
k := strings.ToLower(strings.TrimSpace(key))
|
|
replacer := strings.NewReplacer("é", "e", "è", "e", "ê", "e", "à", "a", "ç", "c")
|
|
return replacer.Replace(k)
|
|
}
|
|
|
|
func splitTableRow(line string) []string {
|
|
trimmed := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(trimmed, "|") {
|
|
return nil
|
|
}
|
|
trimmed = strings.TrimPrefix(trimmed, "|")
|
|
trimmed = strings.TrimSuffix(trimmed, "|")
|
|
parts := strings.Split(trimmed, "|")
|
|
var cells []string
|
|
for _, part := range parts {
|
|
cells = append(cells, strings.TrimSpace(part))
|
|
}
|
|
return cells
|
|
}
|
|
|
|
func isSeparatorRow(cells []string) bool {
|
|
for _, cell := range cells {
|
|
if cell == "" {
|
|
continue
|
|
}
|
|
for _, r := range cell {
|
|
if r != '-' {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseItemRow(header, row []string) (Item, error) {
|
|
if len(row) < len(header) {
|
|
return Item{}, errors.New("row has fewer columns than header")
|
|
}
|
|
idxDesignation := -1
|
|
idxPrice := -1
|
|
idxQty := -1
|
|
for i, col := range header {
|
|
n := normalizeKey(col)
|
|
switch {
|
|
case strings.Contains(n, "designation"):
|
|
idxDesignation = i
|
|
case strings.Contains(n, "unitprice"), strings.Contains(n, "unit price"), strings.Contains(n, "price"), strings.Contains(n, "prix"):
|
|
idxPrice = i
|
|
case strings.Contains(n, "quantity"), strings.Contains(n, "quantite"), strings.Contains(n, "qty"):
|
|
idxQty = i
|
|
}
|
|
}
|
|
if idxDesignation == -1 || idxPrice == -1 || idxQty == -1 {
|
|
return Item{}, errors.New("table header must include Designation, Unit price, Quantity")
|
|
}
|
|
item := Item{Designation: row[idxDesignation]}
|
|
if item.Designation == "" {
|
|
return Item{}, errors.New("empty designation")
|
|
}
|
|
price, err := ParseMoney(row[idxPrice])
|
|
if err != nil {
|
|
return Item{}, fmt.Errorf("invalid unit price: %w", err)
|
|
}
|
|
item.UnitPrice = price
|
|
qty, err := ParseQuantity(row[idxQty])
|
|
if err != nil {
|
|
return Item{}, fmt.Errorf("invalid quantity: %w", err)
|
|
}
|
|
item.Quantity = qty
|
|
return item, nil
|
|
}
|