up
This commit is contained in:
269
internal/loan/pdf_parser.go
Normal file
269
internal/loan/pdf_parser.go
Normal 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
|
||||
Reference in New Issue
Block a user