Files
RentalManager/internal/document/document.go
2026-04-11 12:12:07 +02:00

331 lines
9.1 KiB
Go

package document
import (
"archive/zip"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Model ─────────────────────────────────────────────────────────────────────
type Document struct {
ID string `json:"id"`
PropertyID string `json:"property_id"`
TransactionID string `json:"transaction_id,omitempty"`
Filename string `json:"filename"`
OriginalName string `json:"original_name"`
FilePath string `json:"-"`
MimeType string `json:"mime_type"`
FiscalYear int `json:"fiscal_year"`
Category string `json:"category"`
UploadedBy string `json:"uploaded_by"`
CreatedAt time.Time `json:"created_at"`
// Joint
PropertyName string `json:"property_name,omitempty"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// Migrate ajoute la colonne category si elle n'existe pas encore.
func (s *Store) Migrate() {
s.db.Exec(`ALTER TABLE documents ADD COLUMN doc_month INTEGER NOT NULL DEFAULT 0`)
s.db.Exec(`ALTER TABLE documents ADD COLUMN category TEXT NOT NULL DEFAULT ''`)
}
func (s *Store) List(propertyID string, fiscalYear int, category string) ([]Document, error) {
query := `
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
COALESCE(d.category,''),
COALESCE(d.uploaded_by,''), d.created_at, p.name
FROM documents d
JOIN properties p ON p.id = d.property_id
WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND d.property_id=?"
args = append(args, propertyID)
}
if fiscalYear > 0 {
query += " AND d.fiscal_year=?"
args = append(args, fiscalYear)
}
if category != "" {
query += " AND d.category=?"
args = append(args, category)
}
query += " ORDER BY d.fiscal_year DESC, d.created_at DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []Document
for rows.Next() {
var d Document
if err := rows.Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
&d.UploadedBy, &d.CreatedAt, &d.PropertyName); err != nil {
return nil, err
}
docs = append(docs, d)
}
return docs, nil
}
func (s *Store) Get(id string) (*Document, error) {
var d Document
err := s.db.QueryRow(`
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
COALESCE(d.category,''),
COALESCE(d.uploaded_by,''), d.created_at, p.name
FROM documents d
JOIN properties p ON p.id = d.property_id
WHERE d.id=?`, id,
).Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
&d.UploadedBy, &d.CreatedAt, &d.PropertyName)
return &d, err
}
func (s *Store) Create(d *Document) error {
_, err := s.db.Exec(
`INSERT INTO documents (id, property_id, transaction_id, filename, original_name, file_path, mime_type, fiscal_year, category, uploaded_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
d.ID, d.PropertyID, nullStr(d.TransactionID), d.Filename, d.OriginalName, d.FilePath,
d.MimeType, nullInt(d.FiscalYear), nullStr(d.Category), nullStr(d.UploadedBy),
)
return err
}
func (s *Store) Delete(id string) (string, error) {
var path string
s.db.QueryRow(`SELECT file_path FROM documents WHERE id=?`, id).Scan(&path)
_, err := s.db.Exec(`DELETE FROM documents WHERE id=?`, id)
return path, err
}
func (s *Store) GetByPropertyAndYear(propertyID string, year int) ([]Document, error) {
return s.List(propertyID, year, "")
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct {
store *Store
dataDir string
}
func NewHandler(store *Store, dataDir string) *Handler {
os.MkdirAll(dataDir, 0755)
return &Handler{store: store, dataDir: dataDir}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var year int
fmt.Sscanf(q.Get("fiscal_year"), "%d", &year)
docs, err := h.store.List(q.Get("property_id"), year, q.Get("category"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if docs == nil {
docs = []Document{}
}
respond(w, docs)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
d, err := h.store.Get(mux.Vars(r)["id"])
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, d)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(32 << 20)
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "fichier requis", http.StatusBadRequest)
return
}
defer file.Close()
id := uuid.NewString()
ext := filepath.Ext(header.Filename)
filename := id + ext
propertyID := r.FormValue("property_id")
year := r.FormValue("fiscal_year")
dir := filepath.Join(h.dataDir, propertyID, year)
os.MkdirAll(dir, 0755)
destPath := filepath.Join(dir, filename)
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, "erreur création fichier", http.StatusInternalServerError)
return
}
defer dest.Close()
io.Copy(dest, file)
var fiscalYear int
fmt.Sscanf(year, "%d", &fiscalYear)
d := &Document{
ID: id,
PropertyID: propertyID,
TransactionID: r.FormValue("transaction_id"),
Filename: filename,
OriginalName: header.Filename,
FilePath: destPath,
MimeType: header.Header.Get("Content-Type"),
FiscalYear: fiscalYear,
Category: r.FormValue("category"),
UploadedBy: r.FormValue("uploaded_by"),
}
if err := h.store.Create(d); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, d)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
d, err := h.store.Get(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, d.OriginalName))
if d.MimeType != "" {
w.Header().Set("Content-Type", d.MimeType)
}
http.ServeFile(w, r, d.FilePath)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
path, err := h.store.Delete(mux.Vars(r)["id"])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if path != "" {
os.Remove(path)
}
w.WriteHeader(http.StatusNoContent)
}
// Export génère un ZIP avec la structure : année/catégorie/fichier
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
propertyID := q.Get("property_id")
yearStr := q.Get("year")
var year int
fmt.Sscanf(yearStr, "%d", &year)
if year == 0 {
year = time.Now().Year()
yearStr = fmt.Sprintf("%d", year)
}
docs, err := h.store.List(propertyID, year, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(docs) == 0 {
http.Error(w, "aucun document pour ces critères", http.StatusNotFound)
return
}
propLabel := "tous"
if propertyID != "" {
if docs[0].PropertyName != "" {
propLabel = sanitizeName(docs[0].PropertyName)
} else {
propLabel = propertyID[:8]
}
}
filename := fmt.Sprintf("documents_%s_%s.zip", propLabel, yearStr)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
zw := zip.NewWriter(w)
defer zw.Close()
for _, d := range docs {
catDir := "Sans catégorie"
if strings.TrimSpace(d.Category) != "" {
catDir = sanitizeName(d.Category)
}
zipPath := filepath.Join(yearStr, catDir, d.OriginalName)
zipPath = strings.ReplaceAll(zipPath, "\\", "/")
f, err := os.Open(d.FilePath)
if err != nil {
continue
}
entry, err := zw.Create(zipPath)
if err != nil {
f.Close()
continue
}
io.Copy(entry, f)
f.Close()
}
}
func sanitizeName(s string) string {
replacer := strings.NewReplacer(
"/", "-", "\\", "-", ":", "-", "*", "-",
"?", "-", "\"", "-", "<", "-", ">", "-", "|", "-",
)
return strings.TrimSpace(replacer.Replace(s))
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func nullStr(s string) any {
if s == "" || strings.TrimSpace(s) == "" {
return nil
}
return s
}
func nullInt(i int) any {
if i == 0 {
return nil
}
return i
}