feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
292
pkg/server/bank_import.go
Normal file
292
pkg/server/bank_import.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleBankStatementImport imports bank statement lines from CSV data.
|
||||
// Accepts JSON body with: journal_id, csv_data, column_mapping, has_header.
|
||||
// After import, optionally triggers auto-matching against open invoices.
|
||||
// Mirrors: odoo/addons/account/wizard/account_bank_statement_import.py
|
||||
func (s *Server) handleBankStatementImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
JournalID int64 `json:"journal_id"`
|
||||
CSVData string `json:"csv_data"`
|
||||
HasHeader bool `json:"has_header"`
|
||||
ColumnMapping bankColumnMapping `json:"column_mapping"`
|
||||
AutoMatch bool `json:"auto_match"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"})
|
||||
return
|
||||
}
|
||||
|
||||
if params.JournalID == 0 || params.CSVData == "" {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "journal_id and csv_data are required"})
|
||||
return
|
||||
}
|
||||
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
}
|
||||
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
Pool: s.pool,
|
||||
UID: uid,
|
||||
CompanyID: companyID,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: "Internal error"})
|
||||
return
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
// Parse CSV
|
||||
reader := csv.NewReader(strings.NewReader(params.CSVData))
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
// Try semicolon separator (common in European bank exports)
|
||||
reader.Comma = detectDelimiter(params.CSVData)
|
||||
|
||||
var allRows [][]string
|
||||
for {
|
||||
row, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: fmt.Sprintf("CSV parse error: %v", err)})
|
||||
return
|
||||
}
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
|
||||
dataRows := allRows
|
||||
if params.HasHeader && len(allRows) > 1 {
|
||||
dataRows = allRows[1:]
|
||||
}
|
||||
|
||||
// Create a bank statement header
|
||||
statementRS := env.Model("account.bank.statement")
|
||||
stmt, err := statementRS.Create(orm.Values{
|
||||
"name": fmt.Sprintf("Import %s", time.Now().Format("2006-01-02 15:04")),
|
||||
"journal_id": params.JournalID,
|
||||
"company_id": companyID,
|
||||
"date": time.Now().Format("2006-01-02"),
|
||||
})
|
||||
if err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Create statement: %v", err)})
|
||||
return
|
||||
}
|
||||
stmtID := stmt.ID()
|
||||
|
||||
// Default column mapping
|
||||
cm := params.ColumnMapping
|
||||
if cm.Date < 0 {
|
||||
cm.Date = 0
|
||||
}
|
||||
if cm.Amount < 0 {
|
||||
cm.Amount = 1
|
||||
}
|
||||
if cm.Label < 0 {
|
||||
cm.Label = 2
|
||||
}
|
||||
|
||||
// Import lines
|
||||
lineRS := env.Model("account.bank.statement.line")
|
||||
var importedIDs []int64
|
||||
var errors []importError
|
||||
|
||||
for rowIdx, row := range dataRows {
|
||||
// Parse date
|
||||
dateStr := safeCol(row, cm.Date)
|
||||
date := parseFlexDate(dateStr)
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Parse amount
|
||||
amountStr := safeCol(row, cm.Amount)
|
||||
amount := parseAmount(amountStr)
|
||||
if amount == 0 {
|
||||
continue // skip zero-amount rows
|
||||
}
|
||||
|
||||
// Parse label/reference
|
||||
label := safeCol(row, cm.Label)
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("Line %d", rowIdx+1)
|
||||
}
|
||||
|
||||
// Parse optional columns
|
||||
partnerName := safeCol(row, cm.PartnerName)
|
||||
accountNumber := safeCol(row, cm.AccountNumber)
|
||||
|
||||
vals := orm.Values{
|
||||
"statement_id": stmtID,
|
||||
"journal_id": params.JournalID,
|
||||
"company_id": companyID,
|
||||
"date": date,
|
||||
"amount": amount,
|
||||
"payment_ref": label,
|
||||
"partner_name": partnerName,
|
||||
"account_number": accountNumber,
|
||||
"sequence": rowIdx + 1,
|
||||
}
|
||||
|
||||
rec, err := lineRS.Create(vals)
|
||||
if err != nil {
|
||||
errors = append(errors, importError{Row: rowIdx + 1, Message: err.Error()})
|
||||
log.Printf("bank_import: row %d error: %v", rowIdx+1, err)
|
||||
continue
|
||||
}
|
||||
importedIDs = append(importedIDs, rec.ID())
|
||||
}
|
||||
|
||||
// Auto-match against open invoices
|
||||
matchCount := 0
|
||||
if params.AutoMatch && len(importedIDs) > 0 {
|
||||
stLineModel := orm.Registry.Get("account.bank.statement.line")
|
||||
if stLineModel != nil {
|
||||
if matchMethod, ok := stLineModel.Methods["button_match"]; ok {
|
||||
matchRS := env.Model("account.bank.statement.line").Browse(importedIDs...)
|
||||
if _, err := matchMethod(matchRS); err != nil {
|
||||
log.Printf("bank_import: auto-match error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Count how many were matched
|
||||
env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT COUNT(*) FROM account_bank_statement_line WHERE id = ANY($1) AND is_reconciled = true`,
|
||||
importedIDs).Scan(&matchCount)
|
||||
}
|
||||
|
||||
if err := env.Commit(); err != nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32603, Message: fmt.Sprintf("Commit: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
s.writeJSONRPC(w, req.ID, map[string]interface{}{
|
||||
"statement_id": stmtID,
|
||||
"imported": len(importedIDs),
|
||||
"matched": matchCount,
|
||||
"errors": errors,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// bankColumnMapping maps CSV columns to bank statement fields.
|
||||
type bankColumnMapping struct {
|
||||
Date int `json:"date"` // column index for date
|
||||
Amount int `json:"amount"` // column index for amount
|
||||
Label int `json:"label"` // column index for label/reference
|
||||
PartnerName int `json:"partner_name"` // column index for partner name (-1 = skip)
|
||||
AccountNumber int `json:"account_number"` // column index for account number (-1 = skip)
|
||||
}
|
||||
|
||||
// detectDelimiter guesses the CSV delimiter (comma, semicolon, or tab).
|
||||
func detectDelimiter(data string) rune {
|
||||
firstLine := data
|
||||
if idx := strings.IndexByte(data, '\n'); idx > 0 {
|
||||
firstLine = data[:idx]
|
||||
}
|
||||
semicolons := strings.Count(firstLine, ";")
|
||||
commas := strings.Count(firstLine, ",")
|
||||
tabs := strings.Count(firstLine, "\t")
|
||||
|
||||
if semicolons > commas && semicolons > tabs {
|
||||
return ';'
|
||||
}
|
||||
if tabs > commas {
|
||||
return '\t'
|
||||
}
|
||||
return ','
|
||||
}
|
||||
|
||||
// safeCol returns the value at index i, or "" if out of bounds.
|
||||
func safeCol(row []string, i int) string {
|
||||
if i < 0 || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
|
||||
// parseFlexDate tries multiple date formats and returns YYYY-MM-DD.
|
||||
func parseFlexDate(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"02.01.2006", // DD.MM.YYYY (common in EU)
|
||||
"01/02/2006", // MM/DD/YYYY
|
||||
"02/01/2006", // DD/MM/YYYY
|
||||
"2006/01/02",
|
||||
"Jan 2, 2006",
|
||||
"2 Jan 2006",
|
||||
"02-01-2006",
|
||||
"01-02-2006",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseAmount parses a monetary amount string, handling comma/dot decimals and negative formats.
|
||||
func parseAmount(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
// Remove currency symbols and whitespace
|
||||
s = strings.NewReplacer("€", "", "$", "", "£", "", " ", "", "\u00a0", "").Replace(s)
|
||||
|
||||
// Handle European format: 1.234,56 → 1234.56
|
||||
if strings.Contains(s, ",") && strings.Contains(s, ".") {
|
||||
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
|
||||
// comma is decimal: 1.234,56
|
||||
s = strings.ReplaceAll(s, ".", "")
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
} else {
|
||||
// dot is decimal: 1,234.56
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
}
|
||||
} else if strings.Contains(s, ",") {
|
||||
// Only comma: assume decimal separator
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
}
|
||||
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user