Import initail de calcul-astreintes v0.9.4 pour passage en paycheck 1.0
This commit is contained in:
74
internal/calc/compute.go
Normal file
74
internal/calc/compute.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package calc
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"git.dumerain.org/alban/calcul-astreintes/internal/models"
|
||||
)
|
||||
|
||||
/*
|
||||
Le moteur de calcul est volontairement isolé du reste.
|
||||
Ça facilite :
|
||||
- les tests unitaires
|
||||
- l'évolution des règles
|
||||
- le support de nouveaux codes
|
||||
*/
|
||||
|
||||
func round2(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
func Compute(req models.CalculateRequest, profile models.Profile, rules models.GlobalRules) models.CalculateResponse {
|
||||
// Chaque ligne = taux * quantité, arrondi au centime.
|
||||
// Libellés volontairement "fiche de paie" (courts) pour que UI + PDF collent.
|
||||
lines := []models.Line{
|
||||
{
|
||||
Code: "456",
|
||||
Label: "Interv.astreinte Jour",
|
||||
Rate: profile.T456,
|
||||
Quantity: req.Q456,
|
||||
Unit: "h",
|
||||
Amount: round2(profile.T456 * req.Q456),
|
||||
},
|
||||
{
|
||||
Code: "458",
|
||||
Label: "Interv.astreinte Dim & JF",
|
||||
Rate: profile.T458,
|
||||
Quantity: req.Q458,
|
||||
Unit: "h",
|
||||
Amount: round2(profile.T458 * req.Q458),
|
||||
},
|
||||
{
|
||||
Code: "459",
|
||||
Label: "Interv.astreinte Nuit",
|
||||
Rate: profile.T459,
|
||||
Quantity: req.Q459,
|
||||
Unit: "h",
|
||||
Amount: round2(profile.T459 * req.Q459),
|
||||
},
|
||||
{
|
||||
Code: "471",
|
||||
Label: "Astreinte",
|
||||
Rate: profile.T471,
|
||||
Quantity: req.Q471,
|
||||
Unit: "h",
|
||||
Amount: round2(profile.T471 * req.Q471),
|
||||
},
|
||||
{
|
||||
Code: "480",
|
||||
Label: "Ind. Forfait. Dim & JF",
|
||||
Rate: rules.ForfaitDimFerie,
|
||||
Quantity: float64(req.NbDimFerie),
|
||||
Unit: "j",
|
||||
Amount: round2(rules.ForfaitDimFerie * float64(req.NbDimFerie)),
|
||||
},
|
||||
}
|
||||
|
||||
// Total = somme des montants déjà arrondis (comportement typique paie).
|
||||
total := 0.0
|
||||
for _, l := range lines {
|
||||
total += l.Amount
|
||||
}
|
||||
|
||||
return models.CalculateResponse{Lines: lines, Total: round2(total)}
|
||||
}
|
||||
205
internal/importer/xlsx.go
Normal file
205
internal/importer/xlsx.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.dumerain.org/alban/calcul-astreintes/internal/models"
|
||||
)
|
||||
|
||||
// Feuille de mois acceptée :
|
||||
// - YYYY-MM (ex: 2025-01)
|
||||
// - MM-YYYY (ex: 01-2025)
|
||||
var sheetRe = regexp.MustCompile(`^(\d{4})-(\d{2})$|^(\d{2})-(\d{4})$`)
|
||||
|
||||
func normalizeYearMonth(sheet string) (string, bool) {
|
||||
sheet = strings.TrimSpace(sheet)
|
||||
m := sheetRe.FindStringSubmatch(sheet)
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
// YYYY-MM
|
||||
if m[1] != "" && m[2] != "" {
|
||||
return fmt.Sprintf("%s-%s", m[1], m[2]), true
|
||||
}
|
||||
// MM-YYYY
|
||||
if m[3] != "" && m[4] != "" {
|
||||
return fmt.Sprintf("%s-%s", m[4], m[3]), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseNumberFR convertit "1,92" / "1.92" / "" -> float64
|
||||
func parseNumberFR(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\u00a0", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
|
||||
func isMatriculeCell(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FindMonthSheet: prend la première feuille qui match YYYY-MM ou MM-YYYY, sinon la première feuille.
|
||||
func FindMonthSheet(f *excelize.File) (string, error) {
|
||||
sheets := f.GetSheetList()
|
||||
for _, sh := range sheets {
|
||||
if _, ok := normalizeYearMonth(sh); ok {
|
||||
return strings.TrimSpace(sh), nil
|
||||
}
|
||||
}
|
||||
if len(sheets) == 0 {
|
||||
return "", fmt.Errorf("aucune feuille trouvée dans le fichier")
|
||||
}
|
||||
return sheets[0], nil
|
||||
}
|
||||
|
||||
// ParseAgentsAndTotals lit la feuille détectée et renvoie agents + totaux.
|
||||
// (Le mois est géré côté app/app.go via normalizeYearMonth)
|
||||
func ParseAgentsAndTotals(path string) ([]models.Agent, map[string]models.AgentTotals, error) {
|
||||
f, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
sheet, err := FindMonthSheet(f)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
agents := []models.Agent{}
|
||||
totals := map[string]models.AgentTotals{}
|
||||
|
||||
// Dedup P_DIM-JF par agent (par date)
|
||||
dimDates := map[string]map[string]struct{}{}
|
||||
currentMatricule := ""
|
||||
|
||||
cell := func(col string, excelRow int) string {
|
||||
v, _ := f.GetCellValue(sheet, fmt.Sprintf("%s%d", col, excelRow))
|
||||
return v
|
||||
}
|
||||
|
||||
for i := 0; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
|
||||
colA := ""
|
||||
if len(row) >= 1 {
|
||||
colA = row[0]
|
||||
}
|
||||
|
||||
if isMatriculeCell(colA) {
|
||||
currentMatricule = strings.TrimSpace(colA)
|
||||
|
||||
if _, ok := totals[currentMatricule]; !ok {
|
||||
fullName := ""
|
||||
if len(row) >= 2 {
|
||||
fullName = strings.TrimSpace(row[1]) // Col B = "NOM Prenom"
|
||||
}
|
||||
|
||||
nom, prenom := "", ""
|
||||
if fullName != "" {
|
||||
parts := strings.Fields(fullName)
|
||||
if len(parts) >= 1 {
|
||||
nom = parts[0]
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
prenom = strings.Join(parts[1:], " ")
|
||||
}
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(strings.Join([]string{nom, prenom}, " "))
|
||||
if displayName == "" {
|
||||
displayName = currentMatricule
|
||||
}
|
||||
display := fmt.Sprintf("%s (%s)", displayName, currentMatricule)
|
||||
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
totalExcelRow := i // ligne au-dessus en Excel (cf logique actuelle)
|
||||
|
||||
q471 := parseNumberFR(cell("G", totalExcelRow))
|
||||
q456 := parseNumberFR(cell("L", totalExcelRow))
|
||||
q459 := parseNumberFR(cell("M", totalExcelRow))
|
||||
q458 := parseNumberFR(cell("N", totalExcelRow))
|
||||
|
||||
agents = append(agents, models.Agent{
|
||||
Matricule: currentMatricule,
|
||||
Nom: nom,
|
||||
Prenom: prenom,
|
||||
Display: display,
|
||||
})
|
||||
|
||||
totals[currentMatricule] = models.AgentTotals{
|
||||
Matricule: currentMatricule,
|
||||
Q471: q471,
|
||||
Q456: q456,
|
||||
Q459: q459,
|
||||
Q458: q458,
|
||||
NbDimFerie: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentMatricule != "" {
|
||||
typeLigne := ""
|
||||
if len(row) >= 3 {
|
||||
typeLigne = strings.TrimSpace(row[2]) // Col C
|
||||
}
|
||||
if typeLigne == "P_DIM-JF" {
|
||||
dateStr := ""
|
||||
if len(row) >= 4 {
|
||||
dateStr = strings.TrimSpace(row[3]) // Col D
|
||||
}
|
||||
if _, ok := dimDates[currentMatricule]; !ok {
|
||||
dimDates[currentMatricule] = map[string]struct{}{}
|
||||
}
|
||||
if dateStr != "" {
|
||||
dimDates[currentMatricule][dateStr] = struct{}{}
|
||||
} else {
|
||||
dimDates[currentMatricule][fmt.Sprintf("row-%d", i+1)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for mat, t := range totals {
|
||||
if s, ok := dimDates[mat]; ok {
|
||||
t.NbDimFerie = len(s)
|
||||
totals[mat] = t
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(agents, func(i, j int) bool {
|
||||
return strings.ToLower(agents[i].Display) < strings.ToLower(agents[j].Display)
|
||||
})
|
||||
|
||||
return agents, totals, nil
|
||||
}
|
||||
21
internal/models/import.go
Normal file
21
internal/models/import.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
// Agent = une personne trouvée dans le fichier (affichage + clé matricule)
|
||||
type Agent struct {
|
||||
Matricule string `json:"matricule"`
|
||||
Nom string `json:"nom"`
|
||||
Prenom string `json:"prenom"`
|
||||
Display string `json:"display"` // "NOM Prenom (matricule)"
|
||||
}
|
||||
|
||||
// AgentTotals = les totaux "Total Agent" (ligne du dessus)
|
||||
type AgentTotals struct {
|
||||
Matricule string `json:"matricule"`
|
||||
Q471 float64 `json:"q471"` // Total heures astreinte (col G)
|
||||
Q456 float64 `json:"q456"` // Intervention jour (col L)
|
||||
Q459 float64 `json:"q459"` // Intervention nuit (col M)
|
||||
Q458 float64 `json:"q458"` // Intervention dimanche/ferié (col N)
|
||||
|
||||
// ✅ Nouveau: nombre de dimanches / jours fériés détectés via les lignes P_DIM-JF
|
||||
NbDimFerie int `json:"nbDimFerie"`
|
||||
}
|
||||
57
internal/models/models.go
Normal file
57
internal/models/models.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package models
|
||||
|
||||
// GlobalRules = constantes communes à tous les utilisateurs.
|
||||
type GlobalRules struct {
|
||||
// ForfaitDimFerie = forfait par dimanche / jour férié (€/jour)
|
||||
ForfaitDimFerie float64 `json:"forfait_dim_ferie"`
|
||||
}
|
||||
|
||||
// Profile = taux dépendants de la personne (grade, etc.)
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Matricule string `json:"matricule,omitempty"` // ✅ pour auto-match avec l'agent importé
|
||||
|
||||
// Taux horaires (€/h)
|
||||
T456 float64 `json:"t456"` // Interventions jour hors dimanche/férié
|
||||
T458 float64 `json:"t458"` // Interventions dimanche/jour férié
|
||||
T459 float64 `json:"t459"` // Interventions nuit hors dimanche/férié
|
||||
T471 float64 `json:"t471"` // Astreinte: heures totales du mois
|
||||
}
|
||||
|
||||
// ProfileListItem = ce qu'on expose à l'UI (id + label + matricule pour auto-match)
|
||||
type ProfileListItem struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Matricule string `json:"matricule,omitempty"`
|
||||
}
|
||||
|
||||
// CalculateRequest = saisies mensuelles par l'utilisateur.
|
||||
type CalculateRequest struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
|
||||
// Quantités en heures
|
||||
Q456 float64 `json:"q456"` // heures interventions jour hors dim/férié
|
||||
Q458 float64 `json:"q458"` // heures interventions dimanche/férié
|
||||
Q459 float64 `json:"q459"` // heures interventions nuit hors dim/férié
|
||||
Q471 float64 `json:"q471"` // heures totales astreinte du mois
|
||||
|
||||
// ✅ Nombre de dimanches / jours fériés "au forfait" (auto-rempli à l'import, modifiable)
|
||||
NbDimFerie int `json:"nbDimFerie"`
|
||||
}
|
||||
|
||||
// Line = une ligne de la grille (456/458/459/471/480).
|
||||
type Line struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Rate float64 `json:"rate"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Amount float64 `json:"amount"`
|
||||
Unit string `json:"unit"` // "h" ou "j"
|
||||
}
|
||||
|
||||
// CalculateResponse = résultat du calcul (lignes + total)
|
||||
type CalculateResponse struct {
|
||||
Lines []Line `json:"lines"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
24
internal/models/pdf.go
Normal file
24
internal/models/pdf.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
// ExportPDFMeta = métadonnées utilisées pour nommer le fichier et afficher l'entête du PDF.
|
||||
//
|
||||
// YearMonth doit être au format YYYY-MM.
|
||||
type ExportPDFMeta struct {
|
||||
YearMonth string `json:"yearMonth"`
|
||||
Nom string `json:"nom"`
|
||||
Prenom string `json:"prenom"`
|
||||
Matricule string `json:"matricule"`
|
||||
}
|
||||
|
||||
// ExportPDFRequest = données nécessaires à la génération du PDF.
|
||||
//
|
||||
// Note: on envoie les inputs (CalculateRequest) et on recalcule côté backend
|
||||
// pour garantir que le PDF correspond exactement aux règles/taux du profil.
|
||||
type ExportPDFRequest struct {
|
||||
Meta ExportPDFMeta `json:"meta"`
|
||||
Request CalculateRequest `json:"request"`
|
||||
}
|
||||
|
||||
type ExportPDFResponse struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
BIN
internal/pdf/assets/paychek-logo.png
Normal file
BIN
internal/pdf/assets/paychek-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
BIN
internal/pdf/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
internal/pdf/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
internal/pdf/fonts/DejaVuSans.ttf
Normal file
BIN
internal/pdf/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
237
internal/pdf/pdf.go
Normal file
237
internal/pdf/pdf.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/phpdave11/gofpdf"
|
||||
|
||||
"git.dumerain.org/alban/calcul-astreintes/internal/models"
|
||||
)
|
||||
|
||||
//go:embed fonts/DejaVuSans.ttf fonts/DejaVuSans-Bold.ttf assets/paychek-logo.png
|
||||
var assetFS embed.FS
|
||||
|
||||
func BuildPDF(
|
||||
meta models.ExportPDFMeta,
|
||||
profileLabel string,
|
||||
req models.CalculateRequest,
|
||||
res models.CalculateResponse,
|
||||
) ([]byte, error) {
|
||||
|
||||
regularFont, err := assetFS.ReadFile("fonts/DejaVuSans.ttf")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boldFont, err := assetFS.ReadFile("fonts/DejaVuSans-Bold.ttf")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logoBytes, logoErr := assetFS.ReadFile("assets/paychek-logo.png")
|
||||
hasLogo := logoErr == nil && len(logoBytes) > 0
|
||||
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
if hasLogo {
|
||||
pdf.RegisterImageOptionsReader(
|
||||
"paychek-logo",
|
||||
gofpdf.ImageOptions{ImageType: "PNG"},
|
||||
bytes.NewReader(logoBytes),
|
||||
)
|
||||
}
|
||||
// ✅ IMPORTANT : on désactive l'auto page-break pour éviter une 2e page
|
||||
pdf.SetMargins(12, 14, 12)
|
||||
pdf.SetAutoPageBreak(false, 0)
|
||||
pdf.AddPage()
|
||||
|
||||
pdf.AddUTF8FontFromBytes("DejaVu", "", regularFont)
|
||||
pdf.AddUTF8FontFromBytes("DejaVu", "B", boldFont)
|
||||
|
||||
// A4 en mm
|
||||
pageW := 210.0
|
||||
contentW := 170.0
|
||||
left := (pageW - contentW) / 2.0
|
||||
|
||||
created := time.Now().Format("02/01/2006 15:04")
|
||||
|
||||
fullName := strings.TrimSpace(strings.Join([]string{
|
||||
meta.Nom,
|
||||
meta.Prenom,
|
||||
}, " "))
|
||||
if fullName == "" {
|
||||
fullName = "(nom non renseigné)"
|
||||
}
|
||||
|
||||
// ---------------- TITRE ----------------
|
||||
titleY := pdf.GetY()
|
||||
logoSize := 10.0 // mm
|
||||
gap := 2.5 // espace texte ↔ logo
|
||||
|
||||
pdf.SetFont("DejaVu", "B", 18)
|
||||
|
||||
// Largeur du texte
|
||||
title := "Paychek"
|
||||
textW := pdf.GetStringWidth(title)
|
||||
|
||||
// Largeur totale du bloc (texte + logo)
|
||||
blockW := textW
|
||||
if hasLogo {
|
||||
blockW += gap + logoSize
|
||||
}
|
||||
|
||||
// Position X pour centrer le bloc
|
||||
startX := left + (contentW-blockW)/2
|
||||
|
||||
// Texte
|
||||
pdf.SetXY(startX, titleY)
|
||||
pdf.CellFormat(textW, 10, title, "", 0, "L", false, 0, "")
|
||||
|
||||
// Logo à droite du texte
|
||||
if hasLogo {
|
||||
pdf.ImageOptions(
|
||||
"paychek-logo",
|
||||
startX+textW+gap,
|
||||
titleY,
|
||||
logoSize,
|
||||
logoSize,
|
||||
false,
|
||||
gofpdf.ImageOptions{ImageType: "PNG"},
|
||||
0,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
pdf.Ln(10)
|
||||
|
||||
pdf.SetFont("DejaVu", "", 11)
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW, 6, "Give me my fuc** money", "", 1, "C", false, 0, "")
|
||||
|
||||
// ✅ Séparation franche sous le titre
|
||||
pdf.Ln(6)
|
||||
|
||||
// ✅ CENTRAGE VERTICAL : on place le "bloc principal" à une hauteur stable
|
||||
// (A4, contenu fixe : 2 colonnes + tableau 5 lignes + légende)
|
||||
// -> évite un rendu collé en haut.
|
||||
startY := 48.0
|
||||
pdf.SetY(startY)
|
||||
|
||||
// ---------------- IDENTITÉ / VALEURS ----------------
|
||||
pdf.SetFont("DejaVu", "B", 12)
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW/2-5, 6, "Identité", "", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(contentW/2-5, 6, "Valeurs importées", "", 1, "L", false, 0, "")
|
||||
|
||||
pdf.SetFont("DejaVu", "", 11)
|
||||
|
||||
y := pdf.GetY()
|
||||
pdf.SetXY(left, y)
|
||||
pdf.CellFormat(contentW/2-5, 6, "Mois : "+meta.YearMonth, "", 1, "L", false, 0, "")
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW/2-5, 6, "Agent : "+fullName, "", 1, "L", false, 0, "")
|
||||
if strings.TrimSpace(meta.Matricule) != "" {
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW/2-5, 6, "Matricule : "+meta.Matricule, "", 1, "L", false, 0, "")
|
||||
} else {
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW/2-5, 6, "Matricule : (non renseigné)", "", 1, "L", false, 0, "")
|
||||
}
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW/2-5, 6, "Profil : "+profileLabel, "", 1, "L", false, 0, "")
|
||||
|
||||
rightX := left + contentW/2 + 5
|
||||
pdf.SetXY(rightX, y)
|
||||
pdf.CellFormat(contentW/2-5, 6, fmt.Sprintf("456 : %.2f h", req.Q456), "", 1, "L", false, 0, "")
|
||||
pdf.SetX(rightX)
|
||||
pdf.CellFormat(contentW/2-5, 6, fmt.Sprintf("458 : %.2f h", req.Q458), "", 1, "L", false, 0, "")
|
||||
pdf.SetX(rightX)
|
||||
pdf.CellFormat(contentW/2-5, 6, fmt.Sprintf("459 : %.2f h", req.Q459), "", 1, "L", false, 0, "")
|
||||
pdf.SetX(rightX)
|
||||
pdf.CellFormat(contentW/2-5, 6, fmt.Sprintf("471 : %.2f h", req.Q471), "", 1, "L", false, 0, "")
|
||||
pdf.SetX(rightX)
|
||||
pdf.CellFormat(contentW/2-5, 6, fmt.Sprintf("Dim/JF : %d j", req.NbDimFerie), "", 1, "L", false, 0, "")
|
||||
|
||||
pdf.Ln(8)
|
||||
|
||||
// ---------------- TABLEAU ----------------
|
||||
pdf.SetFont("DejaVu", "B", 12)
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW, 7, "Résultat", "", 1, "L", false, 0, "")
|
||||
|
||||
// Largeurs adaptées (total = 170)
|
||||
colW := []float64{24, 52, 38, 22, 34}
|
||||
headerColor := []int{197, 227, 242}
|
||||
|
||||
pdf.SetFont("DejaVu", "B", 9)
|
||||
pdf.SetFillColor(headerColor[0], headerColor[1], headerColor[2])
|
||||
pdf.SetX(left)
|
||||
headers := []string{"Code paie", "Libellé", "Nombre", "Base", "À payer"}
|
||||
for i, h := range headers {
|
||||
align := "L"
|
||||
if i >= 2 {
|
||||
align = "R"
|
||||
}
|
||||
pdf.CellFormat(colW[i], 7, h, "1", 0, align, true, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
|
||||
pdf.SetFont("DejaVu", "", 9)
|
||||
for _, l := range res.Lines {
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(colW[0], 7, l.Code, "1", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[1], 7, l.Label, "1", 0, "L", false, 0, "")
|
||||
pdf.CellFormat(colW[2], 7, fmt.Sprintf("%.2f %s", l.Quantity, l.Unit), "1", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[3], 7, fmt.Sprintf("%.2f", l.Rate), "1", 0, "R", false, 0, "")
|
||||
pdf.CellFormat(colW[4], 7, fmt.Sprintf("%.2f", l.Amount), "1", 0, "R", false, 0, "")
|
||||
pdf.Ln(-1)
|
||||
}
|
||||
|
||||
// TOTAL BRUT (fond jaune clair)
|
||||
pdf.SetFont("DejaVu", "B", 10)
|
||||
pdf.SetFillColor(255, 249, 196)
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(colW[0]+colW[1]+colW[2]+colW[3], 7, "TOTAL BRUT", "1", 0, "L", true, 0, "")
|
||||
pdf.CellFormat(colW[4], 7, fmt.Sprintf("%.2f", res.Total), "1", 1, "R", true, 0, "")
|
||||
|
||||
pdf.Ln(6)
|
||||
|
||||
// ---------------- LÉGENDE ----------------
|
||||
pdf.SetFont("DejaVu", "B", 9)
|
||||
pdf.SetX(left)
|
||||
pdf.CellFormat(contentW, 5, "Correspondance code paie / fichier Excel", "", 1, "L", false, 0, "")
|
||||
|
||||
pdf.SetFont("DejaVu", "", 8)
|
||||
legend := "" +
|
||||
"• 456 → colonne L (Interv.astreinte Jour)\n" +
|
||||
"• 458 → colonne N (Interv.astreinte Dim & JF)\n" +
|
||||
"• 459 → colonne M (Interv.astreinte Nuit)\n" +
|
||||
"• 471 → colonne G (Astreinte = total d'heures du mois)\n" +
|
||||
"• 480 → déduit des lignes \"P_DIM-JF\" (colonne C) → compteur Dim/JF\n"
|
||||
pdf.SetX(left)
|
||||
pdf.MultiCell(contentW, 4.2, legend, "", "L", false)
|
||||
|
||||
// ---------------- FOOTER (toujours sur la page 1) ----------------
|
||||
// ✅ Position absolue proche du bas (A4=297mm) + marges
|
||||
pdf.SetFont("DejaVu", "", 8)
|
||||
pdf.SetTextColor(120, 120, 120)
|
||||
pdf.SetXY(12, 284)
|
||||
pdf.CellFormat(
|
||||
186,
|
||||
6,
|
||||
fmt.Sprintf("Paychek - Made with \u2665 in Go by Flooze Corp \u00B7 A Niarmud Nablax Company - Exporté le %s", created),
|
||||
"",
|
||||
0,
|
||||
"C",
|
||||
false,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pdf.Output(&buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
50
internal/pdf/sanitize.go
Normal file
50
internal/pdf/sanitize.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// SanitizeFilename transforme une chaîne Unicode en nom de fichier sûr :
|
||||
// - supprime les accents (Clément → Clement)
|
||||
// - remplace les espaces par des tirets
|
||||
// - supprime les caractères interdits (Windows / Unix)
|
||||
// - évite les noms vides
|
||||
func SanitizeFilename(input string) string {
|
||||
if input == "" {
|
||||
return "inconnu"
|
||||
}
|
||||
|
||||
// 1) Normalisation Unicode (NFD) pour séparer lettres + accents
|
||||
t := norm.NFD.String(input)
|
||||
|
||||
// 2) Suppression des marques diacritiques (accents)
|
||||
sb := strings.Builder{}
|
||||
for _, r := range t {
|
||||
if unicode.Is(unicode.Mn, r) {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
out := sb.String()
|
||||
|
||||
// 3) Remplacements simples
|
||||
out = strings.ReplaceAll(out, " ", "-")
|
||||
out = strings.ReplaceAll(out, "_", "-")
|
||||
|
||||
// 4) Suppression des caractères interdits dans les noms de fichiers
|
||||
// Windows + Unix
|
||||
re := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`)
|
||||
out = re.ReplaceAllString(out, "")
|
||||
|
||||
// 5) Nettoyage final
|
||||
out = strings.Trim(out, "-.")
|
||||
if out == "" {
|
||||
return "inconnu"
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
98
internal/profiles/loader.go
Normal file
98
internal/profiles/loader.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.dumerain.org/alban/calcul-astreintes/internal/models"
|
||||
)
|
||||
|
||||
//go:embed profiles.json
|
||||
var embeddedFS embed.FS
|
||||
|
||||
type profileJSON struct {
|
||||
Label string `json:"label"`
|
||||
Matricule string `json:"matricule,omitempty"` // ✅ pour auto-match avec l'agent importé
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
ForfaitDimFerie float64 `json:"forfait_dim_ferie"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]models.Profile
|
||||
rules models.GlobalRules
|
||||
}
|
||||
|
||||
// NewEmbeddedRepo charge les profils depuis profiles.json (embarqué).
|
||||
func NewEmbeddedRepo() (*Repo, *models.GlobalRules, error) {
|
||||
b, err := embeddedFS.ReadFile("profiles.json")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var raw map[string]profileJSON
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return nil, nil, fmt.Errorf("profiles.json invalide: %w", err)
|
||||
}
|
||||
|
||||
repo := &Repo{profiles: map[string]models.Profile{}}
|
||||
|
||||
for id, p := range raw {
|
||||
repo.profiles[id] = models.Profile{
|
||||
ID: id,
|
||||
Label: p.Label,
|
||||
Matricule: p.Matricule,
|
||||
T456: p.Rates["456"],
|
||||
T458: p.Rates["458"],
|
||||
T459: p.Rates["459"],
|
||||
T471: p.Rates["471"],
|
||||
}
|
||||
if repo.rules.ForfaitDimFerie == 0 {
|
||||
repo.rules.ForfaitDimFerie = p.ForfaitDimFerie
|
||||
}
|
||||
}
|
||||
|
||||
// Valeur par défaut de sécurité si le JSON n'a aucun profil
|
||||
if repo.rules.ForfaitDimFerie == 0 {
|
||||
repo.rules.ForfaitDimFerie = 120
|
||||
}
|
||||
|
||||
return repo, &repo.rules, nil
|
||||
}
|
||||
|
||||
// List renvoie les profils triés par label (dropdown stable).
|
||||
func (r *Repo) List() ([]models.ProfileListItem, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
items := make([]models.ProfileListItem, 0, len(r.profiles))
|
||||
for _, p := range r.profiles {
|
||||
items = append(items, models.ProfileListItem{
|
||||
ID: p.ID,
|
||||
Label: p.Label,
|
||||
Matricule: p.Matricule,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Label < items[j].Label })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Get(id string) (models.Profile, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
p, ok := r.profiles[id]
|
||||
if !ok {
|
||||
return models.Profile{}, fmt.Errorf("profil introuvable: %s", id)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Rules() models.GlobalRules {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.rules
|
||||
}
|
||||
46
internal/profiles/profiles.json
Normal file
46
internal/profiles/profiles.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"test": {
|
||||
"label": "TEST (fallback)",
|
||||
"matricule": "",
|
||||
"rates": {
|
||||
"456": 1,
|
||||
"458": 1,
|
||||
"459": 1,
|
||||
"471": 1
|
||||
},
|
||||
"forfait_dim_ferie": 120
|
||||
},
|
||||
"alban": {
|
||||
"label": "DUMERAIN Alban (165004)",
|
||||
"matricule": "165004",
|
||||
"rates": {
|
||||
"456": 18.03,
|
||||
"458": 30.05,
|
||||
"459": 36.06,
|
||||
"471": 3.58
|
||||
},
|
||||
"forfait_dim_ferie": 120
|
||||
},
|
||||
"lydie": {
|
||||
"label": "ROUQUIER Lydie (266340)",
|
||||
"matricule": "266340",
|
||||
"rates": {
|
||||
"456": 15.24,
|
||||
"458": 25.4,
|
||||
"459": 30.48,
|
||||
"471": 3.03
|
||||
},
|
||||
"forfait_dim_ferie": 120
|
||||
},
|
||||
"clement": {
|
||||
"label": "DUFETELLE Clement (178549)",
|
||||
"matricule": "178549",
|
||||
"rates": {
|
||||
"456": 19.05,
|
||||
"458": 31.75,
|
||||
"459": 38.1,
|
||||
"471": 3.78
|
||||
},
|
||||
"forfait_dim_ferie": 120
|
||||
}
|
||||
}
|
||||
15
internal/store/profiles.json
Normal file
15
internal/store/profiles.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"rules": {
|
||||
"forfait_dim_ferie": 120.0
|
||||
},
|
||||
"profiles": [
|
||||
{
|
||||
"id": "alban",
|
||||
"label": "Alban",
|
||||
"t456": 18.03,
|
||||
"t458": 30.05,
|
||||
"t459": 36.06,
|
||||
"t471": 3.58
|
||||
}
|
||||
]
|
||||
}
|
||||
69
internal/store/repo.go
Normal file
69
internal/store/repo.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.dumerain.org/alban/calcul-astreintes/internal/models"
|
||||
)
|
||||
|
||||
/*
|
||||
On embarque internal/store/profiles.json dans le binaire.
|
||||
Ça simplifie énormément le "standalone": pas de fichier externe obligatoire.
|
||||
*/
|
||||
//go:embed profiles.json
|
||||
var embeddedFS embed.FS
|
||||
|
||||
type ProfileRepo struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]models.Profile
|
||||
}
|
||||
|
||||
type embeddedData struct {
|
||||
Rules models.GlobalRules `json:"rules"`
|
||||
Profiles []models.Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
func NewEmbeddedRepo() (*ProfileRepo, *models.GlobalRules, error) {
|
||||
b, err := embeddedFS.ReadFile("profiles.json")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var data embeddedData
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
repo := &ProfileRepo{profiles: map[string]models.Profile{}}
|
||||
for _, p := range data.Profiles {
|
||||
repo.profiles[p.ID] = p
|
||||
}
|
||||
return repo, &data.Rules, nil
|
||||
}
|
||||
|
||||
// List : profils triés par label (pour un dropdown stable).
|
||||
func (r *ProfileRepo) List() ([]models.ProfileListItem, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
items := make([]models.ProfileListItem, 0, len(r.profiles))
|
||||
for _, p := range r.profiles {
|
||||
items = append(items, models.ProfileListItem{ID: p.ID, Label: p.Label})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Label < items[j].Label })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *ProfileRepo) Get(id string) (models.Profile, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
p, ok := r.profiles[id]
|
||||
if !ok {
|
||||
return models.Profile{}, fmt.Errorf("profil introuvable: %s", id)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
Reference in New Issue
Block a user