This commit is contained in:
2026-04-11 12:12:07 +02:00
parent 3bc6e2e080
commit 5b3c5ebb2f
92 changed files with 10948 additions and 35 deletions

269
internal/loan/pdf_parser.go Normal file
View File

@@ -0,0 +1,269 @@
package loan
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"strings"
"github.com/gorilla/mux"
)
// UploadPDF reçoit un PDF, tente de le parser via Python si disponible,
// sinon crée le prêt avec saisie manuelle
func (h *Handler) UploadPDF(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(20 << 20)
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "fichier PDF requis", http.StatusBadRequest)
return
}
defer file.Close()
propertyID := r.FormValue("property_id")
if propertyID == "" {
http.Error(w, "property_id requis", http.StatusBadRequest)
return
}
labelInput := r.FormValue("label")
var buf bytes.Buffer
buf.ReadFrom(file)
pdfBytes := buf.Bytes()
// Tenter l'extraction Python (optionnel)
ref, initialAmount, monthly := extractInfoFallback(pdfBytes)
label := labelInput
if label == "" {
if ref != "" {
label = fmt.Sprintf("Prêt %s", ref)
} else {
label = "Prêt immobilier"
}
}
loan := &Loan{
PropertyID: propertyID,
Label: label,
Reference: ref,
InitialAmount: initialAmount,
MonthlyPayment: monthly,
}
if err := h.store.CreateLoan(loan); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Tenter le parsing des lignes via Python
lines, parseErr := parseLinesWithPython(pdfBytes)
linesImported := 0
if parseErr == nil && len(lines) > 0 {
h.store.InsertLines(loan.ID, lines)
linesImported = len(lines)
} else {
// Pas de Python : essayer les données embarquées selon la référence
switch {
case strings.Contains(ref, "781495"):
lines = GetLoan781495Lines()
case strings.Contains(ref, "781728"):
lines = GetLoan781728Lines()
}
if len(lines) > 0 {
h.store.InsertLines(loan.ID, lines)
linesImported = len(lines)
}
}
respond(w, map[string]any{
"loan": loan,
"lines_imported": linesImported,
"python_used": parseErr == nil,
})
}
// extractInfoFallback tente d'extraire les infos via Python, retourne des zéros si indisponible
func extractInfoFallback(pdfBytes []byte) (ref string, initialAmount, monthly float64) {
script := `
import sys, json, pdfplumber, io, re
data = sys.stdin.buffer.read()
result = {'reference': '', 'initial_amount': 0, 'monthly': 0}
with pdfplumber.open(io.BytesIO(data)) as pdf:
text = pdf.pages[0].extract_text() or ''
m = re.search(r'cr.dit\s*:\s*([\w]+)', text)
if m: result['reference'] = m.group(1).strip()
m = re.search(r'Montant du pr.t\s*:\s*([\d\s,\.]+)\s*EUR', text)
if m:
try: result['initial_amount'] = float(m.group(1).strip().replace(' ','').replace('\u202f','').replace(',','.'))
except: pass
for page in pdf.pages:
for table in (page.extract_tables() or []):
if not table: continue
for row in table[1:]:
if not row or not row[2]: continue
vals=[v.strip() for v in str(row[2]).split('\n') if v.strip()]
if len(vals)>=3 and len(set(vals[:3]))==1:
try:
result['monthly']=float(vals[0].replace(' ','').replace('\u202f','').replace(',','.'))
break
except: pass
if result['monthly']: break
if result['monthly']: break
print(json.dumps(result))
`
py := pythonBin()
if py == "" {
return
}
cmd := exec.Command(py, "-c", script)
cmd.Stdin = bytes.NewReader(pdfBytes)
var out bytes.Buffer
cmd.Stdout = &out
if cmd.Run() != nil {
return
}
var info struct {
Reference string `json:"reference"`
InitialAmount float64 `json:"initial_amount"`
Monthly float64 `json:"monthly"`
}
if json.Unmarshal(out.Bytes(), &info) != nil {
return
}
r := info.Reference
if idx := strings.Index(r, "/"); idx > 0 {
r = strings.TrimSpace(r[:idx])
}
return r, info.InitialAmount, info.Monthly
}
// parseLinesWithPython tente le parsing complet via pdfplumber
func parseLinesWithPython(pdfBytes []byte) ([]LoanLine, error) {
py := pythonBin()
if py == "" {
return nil, fmt.Errorf("python non disponible")
}
script := `
import sys,json,pdfplumber,io
def pa(s):
if not s or not s.strip(): return 0.0
try: return float(s.strip().replace(' ','').replace('\u202f','').replace('\xa0','').replace(',','.'))
except: return 0.0
def pd(s):
s=s.strip()
if '/' in s:
p=s.split('/')
if len(p)==3: return f"{p[2]}-{p[1]}-{p[0]}"
return s
lines=[]
with pdfplumber.open(io.BytesIO(sys.stdin.buffer.read())) as pdf:
for page in pdf.pages:
for table in (page.extract_tables() or []):
if not table or len(table)<2: continue
if not table[0] or 'RANG' not in str(table[0][0]): continue
for row in table[1:]:
if not row or not row[0]: continue
ranks=str(row[0]).split('\n'); dates=str(row[1]).split('\n') if row[1] else []
tots=str(row[2]).split('\n') if row[2] else []; caps=str(row[3]).split('\n') if row[3] else []
ints=str(row[4]).split('\n') if row[4] else []; rems=str(row[5]).split('\n') if row[5] else []
for i,rs in enumerate(ranks):
rs=rs.strip()
if not rs or not rs.isdigit(): continue
c=pa(caps[i] if i<len(caps) else '0')
if c==0: continue
lines.append({'rank':int(rs),'due_date':pd(dates[i].strip() if i<len(dates) else ''),
'total_amount':pa(tots[i] if i<len(tots) else '0'),'capital':c,
'interest':pa(ints[i] if i<len(ints) else '0'),
'remaining_capital':pa(rems[i] if i<len(rems) else '0')})
print(json.dumps(lines))
`
cmd := exec.Command(py, "-c", script)
cmd.Stdin = bytes.NewReader(pdfBytes)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var raw []struct {
Rank int `json:"rank"`
DueDate string `json:"due_date"`
TotalAmount float64 `json:"total_amount"`
Capital float64 `json:"capital"`
Interest float64 `json:"interest"`
RemainingCapital float64 `json:"remaining_capital"`
}
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
return nil, err
}
lines := make([]LoanLine, len(raw))
for i, r := range raw {
lines[i] = LoanLine{Rank: r.Rank, DueDate: r.DueDate, TotalAmount: r.TotalAmount,
Capital: r.Capital, Interest: r.Interest, RemainingCapital: r.RemainingCapital}
}
return lines, nil
}
func pythonBin() string {
for _, name := range []string{"python3", "python"} {
if _, err := exec.LookPath(name); err == nil {
return name
}
}
return ""
}
func respondPDF(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// CreateLoanManual crée un prêt depuis un formulaire JSON
func (h *Handler) CreateLoanManual(w http.ResponseWriter, r *http.Request) {
var payload struct {
PropertyID string `json:"property_id"`
Label string `json:"label"`
Reference string `json:"reference"`
InitialAmount float64 `json:"initial_amount"`
MonthlyPayment float64 `json:"monthly_payment"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
loan := &Loan{
PropertyID: payload.PropertyID,
Label: payload.Label,
Reference: payload.Reference,
InitialAmount: payload.InitialAmount,
MonthlyPayment: payload.MonthlyPayment,
}
if err := h.store.CreateLoan(loan); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Charger les données embarquées selon la référence
var lines []LoanLine
switch {
case strings.Contains(payload.Reference, "781495"):
lines = GetLoan781495Lines()
case strings.Contains(payload.Reference, "781728"):
lines = GetLoan781728Lines()
}
linesImported := 0
if len(lines) > 0 {
h.store.InsertLines(loan.ID, lines)
linesImported = len(lines)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"loan": loan,
"lines_imported": linesImported,
})
}
var _ = mux.Vars // éviter unused import